2026-09-04 14:11:54 -07:00
|
|
|
package action
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"fmt"
|
|
|
|
|
"io"
|
|
|
|
|
"net/http"
|
|
|
|
|
"os"
|
|
|
|
|
"time"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// AssumeRoleInput is the input to AssumeRoleActivity
|
|
|
|
|
type AssumeRoleInput struct {
|
|
|
|
|
// Identity is the user/service identity requesting access
|
|
|
|
|
Identity string `json:"identity"`
|
|
|
|
|
|
|
|
|
|
// ClientID is the OAuth2/OIDC client ID (from vault or env)
|
|
|
|
|
ClientID string `json:"clientId,omitempty"`
|
|
|
|
|
|
|
|
|
|
// ClientSecret is the OAuth2/OIDC client secret (from vault or env)
|
|
|
|
|
ClientSecret string `json:"clientSecret,omitempty"`
|
|
|
|
|
|
|
|
|
|
// Scope defines what APIs this token can access (e.g., "llm:read llm:write")
|
|
|
|
|
Scope string `json:"scope"`
|
|
|
|
|
|
|
|
|
|
// DurationSeconds is how long the token is valid (default: 3600 = 1 hour)
|
|
|
|
|
DurationSeconds int `json:"durationSeconds,omitempty"`
|
|
|
|
|
|
|
|
|
|
// AuthServerURL is the auth server endpoint (from env if not provided)
|
|
|
|
|
AuthServerURL string `json:"authServerUrl,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// AssumeRoleOutput is the output from AssumeRoleActivity
|
|
|
|
|
type AssumeRoleOutput struct {
|
|
|
|
|
// Token is the JWT token for calling api.riotpiao.com
|
|
|
|
|
Token string `json:"token"`
|
|
|
|
|
|
|
|
|
|
// ExpiresAt is when the token expires (Unix timestamp)
|
|
|
|
|
ExpiresAt int64 `json:"expiresAt"`
|
|
|
|
|
|
|
|
|
|
// ExpiresIn is the duration in seconds until expiration
|
|
|
|
|
ExpiresIn int `json:"expiresIn"`
|
|
|
|
|
|
|
|
|
|
// TokenType is typically "Bearer"
|
|
|
|
|
TokenType string `json:"tokenType"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// oauthTokenRequest is sent to the auth server
|
|
|
|
|
type oauthTokenRequest struct {
|
|
|
|
|
GrantType string `json:"grant_type"`
|
|
|
|
|
ClientID string `json:"client_id"`
|
|
|
|
|
ClientSecret string `json:"client_secret"`
|
|
|
|
|
Scope string `json:"scope"`
|
|
|
|
|
Subject string `json:"subject,omitempty"` // The identity being assumed
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// oauthTokenResponse is returned from the auth server
|
|
|
|
|
type oauthTokenResponse struct {
|
|
|
|
|
AccessToken string `json:"access_token"`
|
|
|
|
|
TokenType string `json:"token_type"`
|
|
|
|
|
ExpiresIn int `json:"expires_in"`
|
|
|
|
|
Scope string `json:"scope"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// AssumeRoleActivity requests a temporary JWT token for accessing LLM APIs
|
|
|
|
|
//
|
|
|
|
|
// This activity works like AWS AssumeRole:
|
|
|
|
|
// 1. User provides identity + scope of access needed
|
|
|
|
|
// 2. Activity exchanges credentials with auth server
|
|
|
|
|
// 3. Returns JWT token valid for a limited time
|
|
|
|
|
// 4. Caller uses token in subsequent LLM API calls
|
|
|
|
|
//
|
|
|
|
|
// Security: Credentials should come from vault/secrets, never hardcoded
|
|
|
|
|
func AssumeRoleActivity(ctx context.Context, input *AssumeRoleInput) (*AssumeRoleOutput, error) {
|
2026-09-04 14:13:26 -07:00
|
|
|
// Validate inputs
|
|
|
|
|
if err := validateAssumeRoleInput(input); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Resolve configuration from input + environment
|
|
|
|
|
config, err := resolveAssumeRoleConfig(input)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Request token from auth server
|
|
|
|
|
tokenResp, err := requestAuthToken(ctx, config, input)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Build output
|
|
|
|
|
return buildAssumeRoleOutput(tokenResp), nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// validateAssumeRoleInput checks required fields
|
|
|
|
|
func validateAssumeRoleInput(input *AssumeRoleInput) error {
|
2026-09-04 14:11:54 -07:00
|
|
|
if input.Identity == "" {
|
2026-09-04 14:13:26 -07:00
|
|
|
return fmt.Errorf("identity is required")
|
2026-09-04 14:11:54 -07:00
|
|
|
}
|
|
|
|
|
if input.Scope == "" {
|
2026-09-04 14:13:26 -07:00
|
|
|
return fmt.Errorf("scope is required (e.g., 'llm:read' or 'llm:read llm:write')")
|
2026-09-04 14:11:54 -07:00
|
|
|
}
|
2026-09-04 14:13:26 -07:00
|
|
|
return nil
|
|
|
|
|
}
|
2026-09-04 14:11:54 -07:00
|
|
|
|
2026-09-04 14:13:26 -07:00
|
|
|
// assumeRoleConfig holds resolved configuration
|
|
|
|
|
type assumeRoleConfig struct {
|
|
|
|
|
authServerURL string
|
|
|
|
|
clientID string
|
|
|
|
|
clientSecret string
|
|
|
|
|
duration int
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// resolveAssumeRoleConfig gets config from input or environment
|
|
|
|
|
func resolveAssumeRoleConfig(input *AssumeRoleInput) (*assumeRoleConfig, error) {
|
|
|
|
|
cfg := &assumeRoleConfig{}
|
|
|
|
|
|
|
|
|
|
// Helper function to avoid DRY violation
|
|
|
|
|
getOrEnv := func(val, envKey, fieldName string) (string, error) {
|
|
|
|
|
if val != "" {
|
|
|
|
|
return val, nil
|
2026-09-04 14:11:54 -07:00
|
|
|
}
|
2026-09-04 14:13:26 -07:00
|
|
|
if val = os.Getenv(envKey); val != "" {
|
|
|
|
|
return val, nil
|
2026-09-04 14:11:54 -07:00
|
|
|
}
|
2026-09-04 14:13:26 -07:00
|
|
|
return "", fmt.Errorf("%s not provided and %s not set", fieldName, envKey)
|
2026-09-04 14:11:54 -07:00
|
|
|
}
|
|
|
|
|
|
2026-09-04 14:13:26 -07:00
|
|
|
var err error
|
|
|
|
|
if cfg.authServerURL, err = getOrEnv(input.AuthServerURL, "AUTH_SERVER_URL", "authServerUrl"); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if cfg.clientID, err = getOrEnv(input.ClientID, "OAUTH_CLIENT_ID", "clientId"); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if cfg.clientSecret, err = getOrEnv(input.ClientSecret, "OAUTH_CLIENT_SECRET", "clientSecret"); err != nil {
|
|
|
|
|
return nil, err
|
2026-09-04 14:11:54 -07:00
|
|
|
}
|
|
|
|
|
|
2026-09-04 14:13:26 -07:00
|
|
|
// Validate and set duration
|
|
|
|
|
cfg.duration = input.DurationSeconds
|
|
|
|
|
if cfg.duration == 0 {
|
|
|
|
|
cfg.duration = 3600 // 1 hour default
|
2026-09-04 14:11:54 -07:00
|
|
|
}
|
2026-09-04 14:13:26 -07:00
|
|
|
if cfg.duration > 86400 {
|
|
|
|
|
cfg.duration = 86400 // Max 24 hours
|
2026-09-04 14:11:54 -07:00
|
|
|
}
|
|
|
|
|
|
2026-09-04 14:13:26 -07:00
|
|
|
return cfg, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// requestAuthToken calls the auth server and returns the token response
|
|
|
|
|
func requestAuthToken(ctx context.Context, config *assumeRoleConfig, input *AssumeRoleInput) (*oauthTokenResponse, error) {
|
2026-09-04 14:11:54 -07:00
|
|
|
tokenReq := oauthTokenRequest{
|
|
|
|
|
GrantType: "client_credentials",
|
2026-09-04 14:13:26 -07:00
|
|
|
ClientID: config.clientID,
|
|
|
|
|
ClientSecret: config.clientSecret,
|
2026-09-04 14:11:54 -07:00
|
|
|
Scope: input.Scope,
|
2026-09-04 14:13:26 -07:00
|
|
|
Subject: input.Identity,
|
2026-09-04 14:11:54 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
reqBody, err := json.Marshal(tokenReq)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to marshal token request: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
httpReq, err := http.NewRequestWithContext(ctx, "POST",
|
2026-09-04 14:13:26 -07:00
|
|
|
fmt.Sprintf("%s/oauth/token", config.authServerURL),
|
2026-09-04 14:11:54 -07:00
|
|
|
bytes.NewReader(reqBody))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
|
|
|
|
|
|
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
|
|
|
resp, err := client.Do(httpReq)
|
|
|
|
|
if err != nil {
|
2026-09-04 14:13:26 -07:00
|
|
|
return nil, fmt.Errorf("failed to call auth server: %w", err)
|
2026-09-04 14:11:54 -07:00
|
|
|
}
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
|
|
|
return nil, fmt.Errorf("auth server returned status %d: %s", resp.StatusCode, string(respBody))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var tokenResp oauthTokenResponse
|
|
|
|
|
if err := json.Unmarshal(respBody, &tokenResp); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to unmarshal token response: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if tokenResp.AccessToken == "" {
|
|
|
|
|
return nil, fmt.Errorf("auth server returned empty access token")
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-04 14:13:26 -07:00
|
|
|
return &tokenResp, nil
|
|
|
|
|
}
|
2026-09-04 14:11:54 -07:00
|
|
|
|
2026-09-04 14:13:26 -07:00
|
|
|
// buildAssumeRoleOutput constructs the output from token response
|
|
|
|
|
func buildAssumeRoleOutput(tokenResp *oauthTokenResponse) *AssumeRoleOutput {
|
|
|
|
|
expiresAt := time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Unix()
|
2026-09-04 14:11:54 -07:00
|
|
|
return &AssumeRoleOutput{
|
|
|
|
|
Token: tokenResp.AccessToken,
|
|
|
|
|
ExpiresAt: expiresAt,
|
|
|
|
|
ExpiresIn: tokenResp.ExpiresIn,
|
|
|
|
|
TokenType: tokenResp.TokenType,
|
2026-09-04 14:13:26 -07:00
|
|
|
}
|
2026-09-04 14:11:54 -07:00
|
|
|
}
|