diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4c3fe85..6161608 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,10 @@ existing `0.1.0` release; earlier development prereleases are not listed.
## [Unreleased]
+### Added
+
+- Forward admin and Identity SAT issuance and Forward SAT revocation, using explicit per-call SA Key authentication, typed issuance/revocation metadata, and no automatic retries. Existing platform credentials and PAT/SAT business authentication remain unchanged.
+
## [0.4.0] - 2026-10-12
### Added
diff --git a/Makefile b/Makefile
index 612b48d..c45bbb3 100644
--- a/Makefile
+++ b/Makefile
@@ -124,9 +124,12 @@ test-e2e: test-unit
-json -run 'E2ELive$$' ./forward ./managed > build/test-results/e2e.json; \
result=$$?; echo "E2E result: build/test-results/e2e.json (exit $$result)"; exit $$result
-.PHONY: test-live-sa test-live-sa-execution
+.PHONY: test-live-sa test-live-sa-execution test-live-sa-tokens
test-live-sa:
LIVE_ENV_FILE="$(abspath $(LIVE_ENV_FILE))" go test -tags live -count=1 -v -run TestServiceAccountReadOnlyLive -timeout=5m ./integration
test-live-sa-execution:
LIVE_ENV_FILE="$(abspath $(LIVE_ENV_FILE))" go test -tags live -count=1 -v -run TestServiceAccountExecutionLive -timeout=15m ./integration
+
+test-live-sa-tokens:
+ LIVE_ENV_FILE="$(abspath $(LIVE_ENV_FILE))" go test -tags live -count=1 -v -run '^TestForwardServiceAccountTokensLive$$' -timeout=10m ./integration
diff --git a/README.md b/README.md
index c8d6a10..9c0ebb4 100644
--- a/README.md
+++ b/README.md
@@ -165,6 +165,64 @@ cleanup verification, run `make test-live-sa-execution` with
independent; `QODER_AUTH_MODE` switches examples, not the PAT test suites.
See the [official authentication contract](https://docs.qoder.com/cloud-agents/api/conventions/authentication).
+### Forward SAT management
+
+Forward exposes explicit admin/Identity SAT issuance and revocation in addition
+to automatic platform exchange. These three endpoints authenticate with an
+**SA Key**, supplied per method call; ordinary business requests still use PAT
+or SAT. The Key overrides client/request Authorization only for that call, is
+never included in the JSON body, and is not saved on the client.
+
+`ttl_seconds` is optional (1-43200); omission uses the issuer's default. Issuance
+and revocation are not automatically retried, even when the client or request
+allows retries. Do not blindly repeat an issuance after an uncertain network
+failure: it may already have issued a token. Metadata is for audit information,
+not secrets. Keep the issuance response's Token ID, Credential ID and expiry for
+precise revocation. A returned static SAT does not renew automatically, and
+revocation affects its Forward binding; it does not cancel an already-running
+Session or revoke unrelated Managed/platform access. Never log the SA Key, SAT,
+serialized token response or Authorization headers.
+
+Run `make test-live-sa-tokens` with `QODER_SA_CN_KEY`,
+`QODER_SA_GLOBAL_KEY` and `QODER_SA_LIVE_ALLOW_WRITE=true` to verify both regions.
+This separate suite issues admin and Identity SATs, checks business access and
+cross-Identity refusal, then revokes the tokens, verifies access is refused and
+deletes its temporary Identities. It does not execute a model or require
+`QODER_SA_LIVE_ALLOW_EXECUTION=true`.
+
+```go
+issuer := forward.NewClient(option.WithBaseURL(forwardBaseURL))
+// Admin: issuer.ServiceAccountTokens.New(ctx, the same parameters).
+token, err := issuer.Identities.ServiceAccountTokens.New(ctx, identityID,
+ forward.ServiceAccountTokenNewParams{SAKey: saKey, TTLSeconds: forward.Int(600)})
+if err != nil { return err }
+defer func() {
+ // Use an independent cleanup context if the business context has expired.
+ _, revokeErr := issuer.ServiceAccountTokens.Revoke(context.Background(), token.AuthTokenID,
+ forward.ServiceAccountTokenRevokeParams{
+ SAKey: saKey,
+ TargetCredentialID: forward.String(token.CredentialID),
+ TargetExpiresAt: param.NewOpt(token.ExpiresAt),
+ Reason: forward.String("runtime complete"),
+ })
+ // Report cleanup failures; do not log credentials.
+ if revokeErr != nil { log.Printf("Forward SAT cleanup failed (%T)", revokeErr) }
+}()
+runtime := forward.NewClient(option.WithBaseURL(forwardBaseURL),
+ option.WithAuthMode("sa"), option.WithAuthToken(token.AccessToken))
+page, err := runtime.Sessions.List(ctx, forward.SessionListParams{
+ IdentityIDs: []string{identityID}, Limit: forward.Int(1),
+})
+```
+
+Runnable example: `go run ./examples/auth/forward-tokens`. It revokes its token
+even if the business call fails. Set `QODER_SA_KEY` and `QODER_SA_REGION=cn|global`;
+set `QODER_IDENTITY_ID` for Identity mode, or leave it unset for admin mode.
+
+See the official [admin issuance](https://docs.qoder.com/cloud-agents/api/forward/service-account-tokens/create),
+[Identity issuance](https://docs.qoder.com/cloud-agents/api/forward/service-account-tokens/create-identity)
+and [revocation](https://docs.qoder.com/cloud-agents/api/forward/service-account-tokens/revoke) contracts.
+
## Usage and cancellation
Forward Usage requires PAT or Admin SAT.
diff --git a/docs/api/reference.md b/docs/api/reference.md
index 198687a..62330c1 100644
--- a/docs/api/reference.md
+++ b/docs/api/reference.md
@@ -282,6 +282,9 @@ import "github.com/QoderAI/qoder-cloud-agents-sdk-go/forward"
- [func \(r \*IdentityService\) New\(ctx context.Context, params IdentityNewParams, opts ...option.RequestOption\) \(res \*Identity, err error\)](<#IdentityService.New>)
- [func \(r \*IdentityService\) Stats\(ctx context.Context, opts ...option.RequestOption\) \(res \*IdentityStats, err error\)](<#IdentityService.Stats>)
- [func \(r \*IdentityService\) Update\(ctx context.Context, identityID string, params IdentityUpdateParams, opts ...option.RequestOption\) \(res \*Identity, err error\)](<#IdentityService.Update>)
+- [type IdentityServiceAccountTokenService](<#IdentityServiceAccountTokenService>)
+ - [func NewIdentityServiceAccountTokenService\(opts ...option.RequestOption\) IdentityServiceAccountTokenService](<#NewIdentityServiceAccountTokenService>)
+ - [func \(r \*IdentityServiceAccountTokenService\) New\(ctx context.Context, identityID string, params ServiceAccountTokenNewParams, opts ...option.RequestOption\) \(res \*ServiceAccountToken, err error\)](<#IdentityServiceAccountTokenService.New>)
- [type IdentityStats](<#IdentityStats>)
- [func \(r IdentityStats\) RawJSON\(\) string](<#IdentityStats.RawJSON>)
- [func \(r \*IdentityStats\) UnmarshalJSON\(data \[\]byte\) error](<#IdentityStats.UnmarshalJSON>)
@@ -489,6 +492,28 @@ import "github.com/QoderAI/qoder-cloud-agents-sdk-go/forward"
- [type ScheduleUpdateParams](<#ScheduleUpdateParams>)
- [func \(r ScheduleUpdateParams\) MarshalJSON\(\) \(\[\]byte, error\)](<#ScheduleUpdateParams.MarshalJSON>)
- [func \(r \*ScheduleUpdateParams\) UnmarshalJSON\(data \[\]byte\) error](<#ScheduleUpdateParams.UnmarshalJSON>)
+- [type ServiceAccountToken](<#ServiceAccountToken>)
+ - [func \(r ServiceAccountToken\) GoString\(\) string](<#ServiceAccountToken.GoString>)
+ - [func \(r ServiceAccountToken\) RawJSON\(\) string](<#ServiceAccountToken.RawJSON>)
+ - [func \(r ServiceAccountToken\) String\(\) string](<#ServiceAccountToken.String>)
+ - [func \(r \*ServiceAccountToken\) UnmarshalJSON\(data \[\]byte\) error](<#ServiceAccountToken.UnmarshalJSON>)
+- [type ServiceAccountTokenNewParams](<#ServiceAccountTokenNewParams>)
+ - [func \(r ServiceAccountTokenNewParams\) GoString\(\) string](<#ServiceAccountTokenNewParams.GoString>)
+ - [func \(r ServiceAccountTokenNewParams\) MarshalJSON\(\) \(\[\]byte, error\)](<#ServiceAccountTokenNewParams.MarshalJSON>)
+ - [func \(r ServiceAccountTokenNewParams\) String\(\) string](<#ServiceAccountTokenNewParams.String>)
+ - [func \(r \*ServiceAccountTokenNewParams\) UnmarshalJSON\(data \[\]byte\) error](<#ServiceAccountTokenNewParams.UnmarshalJSON>)
+- [type ServiceAccountTokenRevokeParams](<#ServiceAccountTokenRevokeParams>)
+ - [func \(r ServiceAccountTokenRevokeParams\) GoString\(\) string](<#ServiceAccountTokenRevokeParams.GoString>)
+ - [func \(r ServiceAccountTokenRevokeParams\) MarshalJSON\(\) \(\[\]byte, error\)](<#ServiceAccountTokenRevokeParams.MarshalJSON>)
+ - [func \(r ServiceAccountTokenRevokeParams\) String\(\) string](<#ServiceAccountTokenRevokeParams.String>)
+ - [func \(r \*ServiceAccountTokenRevokeParams\) UnmarshalJSON\(data \[\]byte\) error](<#ServiceAccountTokenRevokeParams.UnmarshalJSON>)
+- [type ServiceAccountTokenRevokeResponse](<#ServiceAccountTokenRevokeResponse>)
+ - [func \(r ServiceAccountTokenRevokeResponse\) RawJSON\(\) string](<#ServiceAccountTokenRevokeResponse.RawJSON>)
+ - [func \(r \*ServiceAccountTokenRevokeResponse\) UnmarshalJSON\(data \[\]byte\) error](<#ServiceAccountTokenRevokeResponse.UnmarshalJSON>)
+- [type ServiceAccountTokenService](<#ServiceAccountTokenService>)
+ - [func NewServiceAccountTokenService\(opts ...option.RequestOption\) ServiceAccountTokenService](<#NewServiceAccountTokenService>)
+ - [func \(r \*ServiceAccountTokenService\) New\(ctx context.Context, params ServiceAccountTokenNewParams, opts ...option.RequestOption\) \(res \*ServiceAccountToken, err error\)](<#ServiceAccountTokenService.New>)
+ - [func \(r \*ServiceAccountTokenService\) Revoke\(ctx context.Context, authTokenID string, params ServiceAccountTokenRevokeParams, opts ...option.RequestOption\) \(res \*ServiceAccountTokenRevokeResponse, err error\)](<#ServiceAccountTokenService.Revoke>)
- [type Session](<#Session>)
- [func \(r Session\) RawJSON\(\) string](<#Session.RawJSON>)
- [func \(r \*Session\) UnmarshalJSON\(data \[\]byte\) error](<#Session.UnmarshalJSON>)
@@ -2021,22 +2046,23 @@ Client exposes Forward resources. Configure options before sharing a client.
```go
type Client struct {
- Options []option.RequestOption
- Templates TemplateService
- Identities IdentityService
- Sessions SessionService
- Schedules ScheduleService
- ScheduleRuns ScheduleRunService
- Batches BatchService
- Channels ChannelService
- ChannelPairings ChannelPairingService
- Environments EnvironmentService
- Files FileService
- Skills SkillService
- Vaults VaultService
- MemoryStores MemoryStoreService
- Usage UsageService
- Models ModelService
+ Options []option.RequestOption
+ Templates TemplateService
+ Identities IdentityService
+ Sessions SessionService
+ Schedules ScheduleService
+ ScheduleRuns ScheduleRunService
+ Batches BatchService
+ Channels ChannelService
+ ChannelPairings ChannelPairingService
+ Environments EnvironmentService
+ Files FileService
+ Skills SkillService
+ Vaults VaultService
+ MemoryStores MemoryStoreService
+ Usage UsageService
+ Models ModelService
+ ServiceAccountTokens ServiceAccountTokenService
}
```
@@ -4120,9 +4146,10 @@ IdentityService provides Forward Identity operations.
```go
type IdentityService struct {
- Options []option.RequestOption
- Configs IdentityConfigService
- MemoryStores IdentityMemoryStoreService
+ Options []option.RequestOption
+ Configs IdentityConfigService
+ MemoryStores IdentityMemoryStoreService
+ ServiceAccountTokens IdentityServiceAccountTokenService
}
```
@@ -4243,6 +4270,35 @@ func (r *IdentityService) Update(ctx context.Context, identityID string, params
Update Identity
+
+## type [IdentityServiceAccountTokenService]()
+
+IdentityServiceAccountTokenService issues SATs bound to a Forward Identity.
+
+```go
+type IdentityServiceAccountTokenService struct {
+ Options []option.RequestOption
+}
+```
+
+
+### func [NewIdentityServiceAccountTokenService]()
+
+```go
+func NewIdentityServiceAccountTokenService(opts ...option.RequestOption) IdentityServiceAccountTokenService
+```
+
+
+
+
+### func \(\*IdentityServiceAccountTokenService\) [New]()
+
+```go
+func (r *IdentityServiceAccountTokenService) New(ctx context.Context, identityID string, params ServiceAccountTokenNewParams, opts ...option.RequestOption) (res *ServiceAccountToken, err error)
+```
+
+New issues an Identity SAT using the explicit SA Key. Automatic retries are disabled.
+
## type [IdentityStats]()
@@ -6844,6 +6900,253 @@ func (r *ScheduleUpdateParams) UnmarshalJSON(data []byte) error
+
+## type [ServiceAccountToken]()
+
+ServiceAccountToken contains a newly issued Forward SAT. Treat AccessToken and RawJSON as secrets. Use WithAuthMode\("sa"\) and WithAuthToken\(AccessToken\) for business calls; renewal is caller\-owned.
+
+```go
+type ServiceAccountToken struct {
+ Type string `json:"type"`
+ AccessToken string `json:"access_token"`
+ TokenType string `json:"token_type"`
+ ExpiresIn int64 `json:"expires_in"`
+ ExpiresAt time.Time `json:"expires_at" format:"date-time"`
+ AuthTokenID string `json:"auth_token_id"`
+ ServiceAccountID string `json:"service_account_id"`
+ CredentialID string `json:"credential_id"`
+ // admin or identity. IdentityID is absent for admin tokens.
+ SubjectType string `json:"subject_type"`
+ IdentityID string `json:"identity_id"`
+ JSON struct {
+ Type respjson.Field
+ AccessToken respjson.Field
+ TokenType respjson.Field
+ ExpiresIn respjson.Field
+ ExpiresAt respjson.Field
+ AuthTokenID respjson.Field
+ ServiceAccountID respjson.Field
+ CredentialID respjson.Field
+ SubjectType respjson.Field
+ IdentityID respjson.Field
+ ExtraFields map[string]respjson.Field
+ // contains filtered or unexported fields
+ } `json:"-"`
+}
+```
+
+
+### func \(ServiceAccountToken\) [GoString]()
+
+```go
+func (r ServiceAccountToken) GoString() string
+```
+
+
+
+
+### func \(ServiceAccountToken\) [RawJSON]()
+
+```go
+func (r ServiceAccountToken) RawJSON() string
+```
+
+
+
+
+### func \(ServiceAccountToken\) [String]()
+
+```go
+func (r ServiceAccountToken) String() string
+```
+
+
+
+
+### func \(\*ServiceAccountToken\) [UnmarshalJSON]()
+
+```go
+func (r *ServiceAccountToken) UnmarshalJSON(data []byte) error
+```
+
+
+
+
+## type [ServiceAccountTokenNewParams]()
+
+
+
+```go
+type ServiceAccountTokenNewParams struct {
+ // SA Key used only in this request's Authorization header; never serialized as JSON.
+ SAKey string `json:"-"`
+ // Lifetime in seconds, 1-43200. Omit to use the issuer's default.
+ TTLSeconds param.Opt[int64] `json:"ttl_seconds,omitzero"`
+ // Business audit metadata; do not include credentials or other secrets.
+ Metadata map[string]any `json:"metadata,omitzero"`
+ // contains filtered or unexported fields
+}
+```
+
+
+### func \(ServiceAccountTokenNewParams\) [GoString]()
+
+```go
+func (r ServiceAccountTokenNewParams) GoString() string
+```
+
+
+
+
+### func \(ServiceAccountTokenNewParams\) [MarshalJSON]()
+
+```go
+func (r ServiceAccountTokenNewParams) MarshalJSON() ([]byte, error)
+```
+
+
+
+
+### func \(ServiceAccountTokenNewParams\) [String]()
+
+```go
+func (r ServiceAccountTokenNewParams) String() string
+```
+
+
+
+
+### func \(\*ServiceAccountTokenNewParams\) [UnmarshalJSON]()
+
+```go
+func (r *ServiceAccountTokenNewParams) UnmarshalJSON(data []byte) error
+```
+
+
+
+
+## type [ServiceAccountTokenRevokeParams]()
+
+
+
+```go
+type ServiceAccountTokenRevokeParams struct {
+ SAKey string `json:"-"`
+ TargetCredentialID param.Opt[string] `json:"target_credential_id,omitzero"`
+ TargetExpiresAt param.Opt[time.Time] `json:"target_expires_at,omitzero" format:"date-time"`
+ Reason param.Opt[string] `json:"reason,omitzero"`
+ // contains filtered or unexported fields
+}
+```
+
+
+### func \(ServiceAccountTokenRevokeParams\) [GoString]()
+
+```go
+func (r ServiceAccountTokenRevokeParams) GoString() string
+```
+
+
+
+
+### func \(ServiceAccountTokenRevokeParams\) [MarshalJSON]()
+
+```go
+func (r ServiceAccountTokenRevokeParams) MarshalJSON() ([]byte, error)
+```
+
+
+
+
+### func \(ServiceAccountTokenRevokeParams\) [String]()
+
+```go
+func (r ServiceAccountTokenRevokeParams) String() string
+```
+
+
+
+
+### func \(\*ServiceAccountTokenRevokeParams\) [UnmarshalJSON]()
+
+```go
+func (r *ServiceAccountTokenRevokeParams) UnmarshalJSON(data []byte) error
+```
+
+
+
+
+## type [ServiceAccountTokenRevokeResponse]()
+
+
+
+```go
+type ServiceAccountTokenRevokeResponse struct {
+ Revoked bool `json:"revoked"`
+ JSON struct {
+ Revoked respjson.Field
+ ExtraFields map[string]respjson.Field
+ // contains filtered or unexported fields
+ } `json:"-"`
+}
+```
+
+
+### func \(ServiceAccountTokenRevokeResponse\) [RawJSON]()
+
+```go
+func (r ServiceAccountTokenRevokeResponse) RawJSON() string
+```
+
+
+
+
+### func \(\*ServiceAccountTokenRevokeResponse\) [UnmarshalJSON]()
+
+```go
+func (r *ServiceAccountTokenRevokeResponse) UnmarshalJSON(data []byte) error
+```
+
+
+
+
+## type [ServiceAccountTokenService]()
+
+ServiceAccountTokenService issues admin SATs and revokes Forward SAT bindings. These methods require a per\-call SA Key, rather than the client's PAT or SAT.
+
+```go
+type ServiceAccountTokenService struct {
+ Options []option.RequestOption
+}
+```
+
+
+### func [NewServiceAccountTokenService]()
+
+```go
+func NewServiceAccountTokenService(opts ...option.RequestOption) ServiceAccountTokenService
+```
+
+
+
+
+### func \(\*ServiceAccountTokenService\) [New]()
+
+```go
+func (r *ServiceAccountTokenService) New(ctx context.Context, params ServiceAccountTokenNewParams, opts ...option.RequestOption) (res *ServiceAccountToken, err error)
+```
+
+New issues an admin SAT. Automatic retries are disabled to avoid duplicate issuance.
+
+
+### func \(\*ServiceAccountTokenService\) [Revoke]()
+
+```go
+func (r *ServiceAccountTokenService) Revoke(ctx context.Context, authTokenID string, params ServiceAccountTokenRevokeParams, opts ...option.RequestOption) (res *ServiceAccountTokenRevokeResponse, err error)
+```
+
+Revoke revokes a Forward SAT binding belonging to the same Service Account. Use the issuance response's CredentialID and ExpiresAt to disambiguate the target.
+
## type [Session]()
diff --git a/examples/auth/forward-tokens/main.go b/examples/auth/forward-tokens/main.go
new file mode 100644
index 0000000..9c104cf
--- /dev/null
+++ b/examples/auth/forward-tokens/main.go
@@ -0,0 +1,81 @@
+// Issue a Forward admin/Identity SAT, make one read-only business call, then revoke it.
+// Set QODER_SA_KEY and QODER_SA_REGION=cn|global. Set QODER_IDENTITY_ID for Identity mode.
+package main
+
+import (
+ "context"
+ "errors"
+ "flag"
+ "fmt"
+ "os"
+ "time"
+
+ "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention/option"
+ "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention/param"
+ "github.com/QoderAI/qoder-cloud-agents-sdk-go/examples/internal/live"
+ "github.com/QoderAI/qoder-cloud-agents-sdk-go/forward"
+)
+
+func main() {
+ flag.Parse()
+ if err := run(); err != nil {
+ fmt.Fprintln(os.Stderr, "Forward token example failed: "+live.Safe(err))
+ os.Exit(1)
+ }
+}
+
+func run() (err error) {
+ key := os.Getenv("QODER_SA_KEY")
+ if key == "" {
+ return fmt.Errorf("configure QODER_SA_KEY")
+ }
+ region := os.Getenv("QODER_SA_REGION")
+ if region == "" {
+ region = "cn"
+ }
+ if region != "cn" && region != "global" {
+ return fmt.Errorf("QODER_SA_REGION must be cn or global")
+ }
+ host := "https://api.qoder.com"
+ if region == "cn" {
+ host = "https://api.qoder.com.cn"
+ }
+ baseURL := host + "/api/v1/forward"
+ issuer := forward.NewClient(option.WithBaseURL(baseURL), option.WithAuthMode("sa"))
+ ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
+ defer cancel()
+ identityID := os.Getenv("QODER_IDENTITY_ID")
+ params := forward.ServiceAccountTokenNewParams{SAKey: key, TTLSeconds: forward.Int(600), Metadata: map[string]any{"purpose": "sdk example"}}
+ var token *forward.ServiceAccountToken
+ if identityID == "" {
+ token, err = issuer.ServiceAccountTokens.New(ctx, params)
+ } else {
+ token, err = issuer.Identities.ServiceAccountTokens.New(ctx, identityID, params)
+ }
+ if err != nil {
+ return err
+ }
+ defer func() {
+ cleanup, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+ _, revokeErr := issuer.ServiceAccountTokens.Revoke(cleanup, token.AuthTokenID, forward.ServiceAccountTokenRevokeParams{
+ SAKey: key, TargetCredentialID: forward.String(token.CredentialID), TargetExpiresAt: param.NewOpt(token.ExpiresAt), Reason: forward.String("sdk example complete"),
+ })
+ err = errors.Join(err, revokeErr)
+ }()
+ runtime := forward.NewClient(option.WithBaseURL(baseURL), option.WithAuthMode("sa"), option.WithAuthToken(token.AccessToken), option.WithMaxRetries(0))
+ if identityID == "" {
+ page, callErr := runtime.Templates.List(ctx, forward.TemplateListParams{Limit: forward.Int(1)})
+ if callErr != nil {
+ return callErr
+ }
+ fmt.Println("Forward templates:", len(page.Data))
+ } else {
+ page, callErr := runtime.Sessions.List(ctx, forward.SessionListParams{IdentityIDs: []string{identityID}, Limit: forward.Int(1)})
+ if callErr != nil {
+ return callErr
+ }
+ fmt.Println("Identity sessions:", len(page.Data))
+ }
+ return nil
+}
diff --git a/forward/client.go b/forward/client.go
index dfa5f89..c8ee011 100644
--- a/forward/client.go
+++ b/forward/client.go
@@ -12,22 +12,23 @@ const DefaultBaseURL = "https://api.qoder.com/api/v1/forward/"
// Client exposes Forward resources. Configure options before sharing a client.
type Client struct {
- Options []option.RequestOption
- Templates TemplateService
- Identities IdentityService
- Sessions SessionService
- Schedules ScheduleService
- ScheduleRuns ScheduleRunService
- Batches BatchService
- Channels ChannelService
- ChannelPairings ChannelPairingService
- Environments EnvironmentService
- Files FileService
- Skills SkillService
- Vaults VaultService
- MemoryStores MemoryStoreService
- Usage UsageService
- Models ModelService
+ Options []option.RequestOption
+ Templates TemplateService
+ Identities IdentityService
+ Sessions SessionService
+ Schedules ScheduleService
+ ScheduleRuns ScheduleRunService
+ Batches BatchService
+ Channels ChannelService
+ ChannelPairings ChannelPairingService
+ Environments EnvironmentService
+ Files FileService
+ Skills SkillService
+ Vaults VaultService
+ MemoryStores MemoryStoreService
+ Usage UsageService
+ Models ModelService
+ ServiceAccountTokens ServiceAccountTokenService
}
// NewClient uses common authentication defaults and QODER_FORWARD_BASE_URL. Explicit options win.
@@ -42,20 +43,21 @@ func NewClient(opts ...option.RequestOption) Client {
defaults = append(defaults, convention.EnvironmentAuthentication())
opts = slices.Concat(defaults, opts)
return Client{Options: opts,
- Templates: NewTemplateService(opts...),
- Identities: NewIdentityService(opts...),
- Sessions: NewSessionService(opts...),
- Schedules: NewScheduleService(opts...),
- ScheduleRuns: NewScheduleRunService(opts...),
- Batches: NewBatchService(opts...),
- Channels: NewChannelService(opts...),
- ChannelPairings: NewChannelPairingService(opts...),
- Environments: NewEnvironmentService(opts...),
- Files: NewFileService(opts...),
- Skills: NewSkillService(opts...),
- Vaults: NewVaultService(opts...),
- MemoryStores: NewMemoryStoreService(opts...),
- Usage: NewUsageService(opts...),
- Models: NewModelService(opts...),
+ Templates: NewTemplateService(opts...),
+ Identities: NewIdentityService(opts...),
+ Sessions: NewSessionService(opts...),
+ Schedules: NewScheduleService(opts...),
+ ScheduleRuns: NewScheduleRunService(opts...),
+ Batches: NewBatchService(opts...),
+ Channels: NewChannelService(opts...),
+ ChannelPairings: NewChannelPairingService(opts...),
+ Environments: NewEnvironmentService(opts...),
+ Files: NewFileService(opts...),
+ Skills: NewSkillService(opts...),
+ Vaults: NewVaultService(opts...),
+ MemoryStores: NewMemoryStoreService(opts...),
+ Usage: NewUsageService(opts...),
+ Models: NewModelService(opts...),
+ ServiceAccountTokens: NewServiceAccountTokenService(opts...),
}
}
diff --git a/forward/client_test.go b/forward/client_test.go
index 8b2d629..4e175a1 100644
--- a/forward/client_test.go
+++ b/forward/client_test.go
@@ -34,8 +34,8 @@ func TestForwardAPIInventory(t *testing.T) {
}
}
walk(root)
- if len(actual) != 113 {
- t.Fatalf("HTTP methods: %d want 113", len(actual))
+ if len(actual) != 116 {
+ t.Fatalf("HTTP methods: %d want 116", len(actual))
}
baseline := map[string]bool{}
for _, c := range operations(t) {
diff --git a/forward/identity.go b/forward/identity.go
index 288bc04..6c0997a 100644
--- a/forward/identity.go
+++ b/forward/identity.go
@@ -19,13 +19,14 @@ import (
// IdentityService provides Forward Identity operations.
type IdentityService struct {
- Options []option.RequestOption
- Configs IdentityConfigService
- MemoryStores IdentityMemoryStoreService
+ Options []option.RequestOption
+ Configs IdentityConfigService
+ MemoryStores IdentityMemoryStoreService
+ ServiceAccountTokens IdentityServiceAccountTokenService
}
func NewIdentityService(opts ...option.RequestOption) IdentityService {
- return IdentityService{Options: slices.Clone(opts), Configs: NewIdentityConfigService(opts...), MemoryStores: NewIdentityMemoryStoreService(opts...)}
+ return IdentityService{Options: slices.Clone(opts), Configs: NewIdentityConfigService(opts...), MemoryStores: NewIdentityMemoryStoreService(opts...), ServiceAccountTokens: NewIdentityServiceAccountTokenService(opts...)}
}
// List Identities
diff --git a/forward/identityserviceaccounttoken.go b/forward/identityserviceaccounttoken.go
new file mode 100644
index 0000000..6530a1d
--- /dev/null
+++ b/forward/identityserviceaccounttoken.go
@@ -0,0 +1,35 @@
+package forward
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/url"
+ "slices"
+
+ "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention"
+ "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention/option"
+)
+
+// IdentityServiceAccountTokenService issues SATs bound to a Forward Identity.
+type IdentityServiceAccountTokenService struct {
+ Options []option.RequestOption
+}
+
+func NewIdentityServiceAccountTokenService(opts ...option.RequestOption) IdentityServiceAccountTokenService {
+ return IdentityServiceAccountTokenService{Options: slices.Clone(opts)}
+}
+
+// New issues an Identity SAT using the explicit SA Key. Automatic retries are disabled.
+func (r *IdentityServiceAccountTokenService) New(ctx context.Context, identityID string, params ServiceAccountTokenNewParams, opts ...option.RequestOption) (res *ServiceAccountToken, err error) {
+ if identityID == "" {
+ return nil, fmt.Errorf("missing required identity_id parameter")
+ }
+ opts, err = serviceAccountTokenOptions(params.SAKey, slices.Concat(r.Options, opts))
+ if err != nil {
+ return nil, err
+ }
+ path := fmt.Sprintf("identities/%s/service_account_tokens", url.PathEscape(identityID))
+ err = convention.ExecuteNewRequest(ctx, http.MethodPost, path, params, &res, opts...)
+ return res, err
+}
diff --git a/forward/serviceaccounttoken.go b/forward/serviceaccounttoken.go
new file mode 100644
index 0000000..f57db08
--- /dev/null
+++ b/forward/serviceaccounttoken.go
@@ -0,0 +1,153 @@
+package forward
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/url"
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention"
+ "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention/apijson"
+ "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention/option"
+ "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention/param"
+ "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention/respjson"
+)
+
+// ServiceAccountTokenService issues admin SATs and revokes Forward SAT bindings.
+// These methods require a per-call SA Key, rather than the client's PAT or SAT.
+type ServiceAccountTokenService struct {
+ Options []option.RequestOption
+}
+
+func NewServiceAccountTokenService(opts ...option.RequestOption) ServiceAccountTokenService {
+ return ServiceAccountTokenService{Options: slices.Clone(opts)}
+}
+
+// New issues an admin SAT. Automatic retries are disabled to avoid duplicate issuance.
+func (r *ServiceAccountTokenService) New(ctx context.Context, params ServiceAccountTokenNewParams, opts ...option.RequestOption) (res *ServiceAccountToken, err error) {
+ opts, err = serviceAccountTokenOptions(params.SAKey, slices.Concat(r.Options, opts))
+ if err != nil {
+ return nil, err
+ }
+ err = convention.ExecuteNewRequest(ctx, http.MethodPost, "service_account_tokens", params, &res, opts...)
+ return res, err
+}
+
+// Revoke revokes a Forward SAT binding belonging to the same Service Account.
+// Use the issuance response's CredentialID and ExpiresAt to disambiguate the target.
+func (r *ServiceAccountTokenService) Revoke(ctx context.Context, authTokenID string, params ServiceAccountTokenRevokeParams, opts ...option.RequestOption) (res *ServiceAccountTokenRevokeResponse, err error) {
+ if authTokenID == "" {
+ return nil, fmt.Errorf("missing required auth_token_id parameter")
+ }
+ opts, err = serviceAccountTokenOptions(params.SAKey, slices.Concat(r.Options, opts))
+ if err != nil {
+ return nil, err
+ }
+ path := fmt.Sprintf("service_account_tokens/%s/revoke", url.PathEscape(authTokenID))
+ err = convention.ExecuteNewRequest(ctx, http.MethodPost, path, params, &res, opts...)
+ return res, err
+}
+
+func serviceAccountTokenOptions(saKey string, opts []option.RequestOption) ([]option.RequestOption, error) {
+ if saKey == "" || strings.ContainsFunc(saKey, func(r rune) bool { return r <= 0x20 || r == 0x7f }) {
+ return nil, fmt.Errorf("sa_key must be non-empty and contain no whitespace or control characters")
+ }
+ // Apply last so client/request auth overrides cannot replace the explicit Key.
+ return append(opts, option.WithHeader("Authorization", "Bearer "+saKey), option.WithHeader("Content-Type", "application/json"), option.WithMaxRetries(0)), nil
+}
+
+type ServiceAccountTokenNewParams struct {
+ // SA Key used only in this request's Authorization header; never serialized as JSON.
+ SAKey string `json:"-"`
+ // Lifetime in seconds, 1-43200. Omit to use the issuer's default.
+ TTLSeconds param.Opt[int64] `json:"ttl_seconds,omitzero"`
+ // Business audit metadata; do not include credentials or other secrets.
+ Metadata map[string]any `json:"metadata,omitzero"`
+ paramObj
+}
+
+func (r ServiceAccountTokenNewParams) MarshalJSON() ([]byte, error) {
+ type shadow ServiceAccountTokenNewParams
+ return param.MarshalObject(r, (*shadow)(&r))
+}
+func (r *ServiceAccountTokenNewParams) UnmarshalJSON(data []byte) error {
+ return apijson.UnmarshalRoot(data, r)
+}
+func (r ServiceAccountTokenNewParams) String() string {
+ return "ServiceAccountTokenNewParams{SAKey: [redacted]}"
+}
+func (r ServiceAccountTokenNewParams) GoString() string { return r.String() }
+
+type ServiceAccountTokenRevokeParams struct {
+ SAKey string `json:"-"`
+ TargetCredentialID param.Opt[string] `json:"target_credential_id,omitzero"`
+ TargetExpiresAt param.Opt[time.Time] `json:"target_expires_at,omitzero" format:"date-time"`
+ Reason param.Opt[string] `json:"reason,omitzero"`
+ paramObj
+}
+
+func (r ServiceAccountTokenRevokeParams) MarshalJSON() ([]byte, error) {
+ type shadow ServiceAccountTokenRevokeParams
+ return param.MarshalObject(r, (*shadow)(&r))
+}
+func (r *ServiceAccountTokenRevokeParams) UnmarshalJSON(data []byte) error {
+ return apijson.UnmarshalRoot(data, r)
+}
+func (r ServiceAccountTokenRevokeParams) String() string {
+ return "ServiceAccountTokenRevokeParams{SAKey: [redacted]}"
+}
+func (r ServiceAccountTokenRevokeParams) GoString() string { return r.String() }
+
+// ServiceAccountToken contains a newly issued Forward SAT. Treat AccessToken and RawJSON as secrets.
+// Use WithAuthMode("sa") and WithAuthToken(AccessToken) for business calls; renewal is caller-owned.
+type ServiceAccountToken struct {
+ Type string `json:"type"`
+ AccessToken string `json:"access_token"`
+ TokenType string `json:"token_type"`
+ ExpiresIn int64 `json:"expires_in"`
+ ExpiresAt time.Time `json:"expires_at" format:"date-time"`
+ AuthTokenID string `json:"auth_token_id"`
+ ServiceAccountID string `json:"service_account_id"`
+ CredentialID string `json:"credential_id"`
+ // admin or identity. IdentityID is absent for admin tokens.
+ SubjectType string `json:"subject_type"`
+ IdentityID string `json:"identity_id"`
+ JSON struct {
+ Type respjson.Field
+ AccessToken respjson.Field
+ TokenType respjson.Field
+ ExpiresIn respjson.Field
+ ExpiresAt respjson.Field
+ AuthTokenID respjson.Field
+ ServiceAccountID respjson.Field
+ CredentialID respjson.Field
+ SubjectType respjson.Field
+ IdentityID respjson.Field
+ ExtraFields map[string]respjson.Field
+ raw string
+ } `json:"-"`
+}
+
+func (r ServiceAccountToken) RawJSON() string { return r.JSON.raw }
+func (r *ServiceAccountToken) UnmarshalJSON(data []byte) error { return apijson.UnmarshalRoot(data, r) }
+func (r ServiceAccountToken) String() string {
+ return fmt.Sprintf("ServiceAccountToken{AuthTokenID: %q, SubjectType: %q, AccessToken: [redacted]}", r.AuthTokenID, r.SubjectType)
+}
+func (r ServiceAccountToken) GoString() string { return r.String() }
+
+type ServiceAccountTokenRevokeResponse struct {
+ Revoked bool `json:"revoked"`
+ JSON struct {
+ Revoked respjson.Field
+ ExtraFields map[string]respjson.Field
+ raw string
+ } `json:"-"`
+}
+
+func (r ServiceAccountTokenRevokeResponse) RawJSON() string { return r.JSON.raw }
+func (r *ServiceAccountTokenRevokeResponse) UnmarshalJSON(data []byte) error {
+ return apijson.UnmarshalRoot(data, r)
+}
diff --git a/forward/serviceaccounttoken_test.go b/forward/serviceaccounttoken_test.go
new file mode 100644
index 0000000..ce584d2
--- /dev/null
+++ b/forward/serviceaccounttoken_test.go
@@ -0,0 +1,204 @@
+package forward_test
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "reflect"
+ "strings"
+ "testing"
+
+ "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention/option"
+ "github.com/QoderAI/qoder-cloud-agents-sdk-go/forward"
+)
+
+func TestServiceAccountTokenAPIContracts(t *testing.T) {
+ for _, id := range []string{"createServiceAccountToken", "createIdentityServiceAccountToken", "revokeServiceAccountToken"} {
+ t.Run(id, func(t *testing.T) {
+ for _, status := range []int{200, 400} {
+ client := contractClient(t, id, status)
+ var res any
+ var err error
+ switch id {
+ case "createServiceAccountToken":
+ res, err = client.ServiceAccountTokens.New(context.Background(), contractParams[forward.ServiceAccountTokenNewParams](t, id))
+ case "createIdentityServiceAccountToken":
+ res, err = client.Identities.ServiceAccountTokens.New(context.Background(), pathSegment, contractParams[forward.ServiceAccountTokenNewParams](t, id))
+ case "revokeServiceAccountToken":
+ res, err = client.ServiceAccountTokens.Revoke(context.Background(), pathSegment, contractParams[forward.ServiceAccountTokenRevokeParams](t, id))
+ }
+ checkError(t, status, err)
+ if status == 200 {
+ checkDecoded(t, res)
+ }
+ }
+ })
+ }
+}
+
+type tokenManagementCredential struct{ calls int }
+
+func (c *tokenManagementCredential) Token(context.Context) (string, error) {
+ c.calls++
+ return "business-token", nil
+}
+
+func TestServiceAccountTokenWithoutBusinessCredentials(t *testing.T) {
+ t.Setenv("QODER_PAT", "")
+ t.Setenv("QODER_ACCESS_TOKEN", "")
+ client := forward.NewClient(option.WithAuthMode("sa"), option.WithBaseURL("https://api.test/api/v1/forward"), option.WithHTTPClient(&http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
+ if req.Header.Get("Authorization") != "Bearer explicit-sa-key" {
+ t.Fatal("incorrect Key authentication")
+ }
+ return reply(req, 201, string(contract(t, "createServiceAccountToken").Response)), nil
+ })}))
+ if _, err := client.ServiceAccountTokens.New(context.Background(), forward.ServiceAccountTokenNewParams{SAKey: "explicit-sa-key"}); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestServiceAccountTokenKeyIsRequestScoped(t *testing.T) {
+ t.Setenv("QODER_PAT", "ambient-pat")
+ t.Setenv("QODER_ACCESS_TOKEN", "ambient-sat")
+ for _, dynamic := range []bool{false, true} {
+ credential := &tokenManagementCredential{}
+ var requests []*http.Request
+ var bodies []map[string]any
+ opts := []option.RequestOption{option.WithPAT("business-token")}
+ if dynamic {
+ opts = []option.RequestOption{option.WithCredential(credential)}
+ }
+ opts = append(opts, option.WithBaseURL("https://api.test/api/v1/forward"), option.WithHTTPClient(&http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
+ requests = append(requests, req)
+ if req.Method == "GET" {
+ return reply(req, 200, `{"data":[]}`), nil
+ }
+ body := map[string]any{}
+ if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
+ t.Fatal(err)
+ }
+ bodies = append(bodies, body)
+ if strings.HasSuffix(req.URL.Path, "/revoke") {
+ return reply(req, 200, `{"revoked":true}`), nil
+ }
+ return reply(req, 201, string(contract(t, "createServiceAccountToken").Response)), nil
+ })}))
+ client := forward.NewClient(opts...)
+ token, err := client.ServiceAccountTokens.New(context.Background(), forward.ServiceAccountTokenNewParams{SAKey: "explicit-sa-key"}, option.WithHeader("authorization", "Bearer wrong"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if credential.calls != 0 {
+ t.Fatal("token management called the business credential")
+ }
+ if _, err = client.Identities.ServiceAccountTokens.New(context.Background(), "idn_one", forward.ServiceAccountTokenNewParams{SAKey: "explicit-sa-key"}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err = client.ServiceAccountTokens.Revoke(context.Background(), token.AuthTokenID, forward.ServiceAccountTokenRevokeParams{SAKey: "explicit-sa-key"}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err = client.Models.List(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ for i, req := range requests {
+ want := "Bearer explicit-sa-key"
+ if i == 3 {
+ want = "Bearer business-token"
+ }
+ if req.Header.Get("Authorization") != want {
+ t.Fatal("incorrect request authentication")
+ }
+ }
+ for _, body := range bodies {
+ if len(body) != 0 {
+ t.Fatalf("omitted parameters must produce {}: %v", body)
+ }
+ }
+ if dynamic && credential.calls != 1 {
+ t.Fatal("business credential was not retained")
+ }
+ if token.AccessToken != "sat-contract-secret" || token.ExpiresAt.IsZero() || token.JSON.IdentityID.Valid() {
+ t.Fatal("admin token fields not decoded")
+ }
+ if strings.Contains(fmt.Sprintf("%+v %#v", token, token), token.AccessToken) {
+ t.Fatal("formatting exposed SAT")
+ }
+ }
+}
+
+func TestServiceAccountTokenNeverRetries(t *testing.T) {
+ for _, method := range []string{"admin", "identity", "revoke"} {
+ for _, status := range []int{429, 503, 0} {
+ t.Run(fmt.Sprintf("%s/%d", method, status), func(t *testing.T) {
+ calls := 0
+ cause := errors.New("connection interrupted")
+ client := testClient(func(req *http.Request) (*http.Response, error) {
+ calls++
+ if status == 0 {
+ return nil, cause
+ }
+ response := reply(req, status, `{"error":{"type":"api_error","message":"issuance uncertain"}}`)
+ response.Header.Set("x-should-retry", "true")
+ response.Header.Set("retry-after-ms", "1")
+ return response, nil
+ }, option.WithMaxRetries(3))
+ opts := []option.RequestOption{option.WithMaxRetries(4), option.WithHeader("Idempotency-Key", "unsupported-key")}
+ var err error
+ switch method {
+ case "admin":
+ _, err = client.ServiceAccountTokens.New(context.Background(), forward.ServiceAccountTokenNewParams{SAKey: "sa-key"}, opts...)
+ case "identity":
+ _, err = client.Identities.ServiceAccountTokens.New(context.Background(), "idn_one", forward.ServiceAccountTokenNewParams{SAKey: "sa-key"}, opts...)
+ case "revoke":
+ _, err = client.ServiceAccountTokens.Revoke(context.Background(), "auth_one", forward.ServiceAccountTokenRevokeParams{SAKey: "sa-key"}, opts...)
+ }
+ if err == nil || calls != 1 {
+ t.Fatalf("err=%v calls=%d", err, calls)
+ }
+ if status == 0 && !errors.Is(err, cause) {
+ t.Fatal("transport cause lost")
+ }
+ })
+ }
+ }
+}
+
+func TestServiceAccountTokenMissingKeyFailsBeforeTransport(t *testing.T) {
+ client := testClient(func(*http.Request) (*http.Response, error) { t.Fatal("invalid Key reached transport"); return nil, nil })
+ for _, key := range []string{"", " ", "key\nInjected:value", "key\t", "key\x00"} {
+ if _, err := client.ServiceAccountTokens.New(context.Background(), forward.ServiceAccountTokenNewParams{SAKey: key}); err == nil {
+ t.Fatal("invalid Key accepted")
+ }
+ if _, err := client.Identities.ServiceAccountTokens.New(context.Background(), "idn_one", forward.ServiceAccountTokenNewParams{SAKey: key}); err == nil {
+ t.Fatal("invalid Key accepted")
+ }
+ if _, err := client.ServiceAccountTokens.Revoke(context.Background(), "auth_one", forward.ServiceAccountTokenRevokeParams{SAKey: key}); err == nil {
+ t.Fatal("invalid Key accepted")
+ }
+ }
+}
+
+func TestServiceAccountTokenJSONDoesNotContainKey(t *testing.T) {
+ params := forward.ServiceAccountTokenNewParams{SAKey: "secret-sa-key", TTLSeconds: forward.Int(43200), Metadata: map[string]any{"purpose": "audit"}}
+ b, err := json.Marshal(params)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var body map[string]any
+ if err = json.NewDecoder(strings.NewReader(string(b))).Decode(&body); err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(body, map[string]any{"ttl_seconds": float64(43200), "metadata": map[string]any{"purpose": "audit"}}) {
+ t.Fatal("incorrect issuance JSON")
+ }
+ if strings.Contains(fmt.Sprintf("%+v %#v", params, params), params.SAKey) {
+ t.Fatal("formatting exposed SA Key")
+ }
+ // The request body must remain {} when only authentication is configured.
+ b, err = json.Marshal(forward.ServiceAccountTokenRevokeParams{SAKey: "secret-sa-key"})
+ if err != nil || string(b) != "{}" {
+ t.Fatalf("body=%s err=%v", b, err)
+ }
+}
diff --git a/forward/test_helpers_test.go b/forward/test_helpers_test.go
index 19d5925..21359e2 100644
--- a/forward/test_helpers_test.go
+++ b/forward/test_helpers_test.go
@@ -36,6 +36,7 @@ func testClient(fn roundTripFunc, opts ...option.RequestOption) forward.Client {
type operationCase struct {
OperationID string `json:"operation_id"`
+ Auth string `json:"auth"`
Method string `json:"http_method"`
Path string `json:"path"`
Parameters []parameterCase `json:"parameters"`
@@ -85,8 +86,8 @@ func operations(t *testing.T) []operationCase {
Cases []operationCase `json:"cases"`
}
readJSON(t, "testdata/api-operation-cases.json", &fixture)
- if fixture.Count != 113 || len(fixture.Cases) != 113 {
- t.Fatal("Forward API scope must contain 113 operations")
+ if fixture.Count != 116 || len(fixture.Cases) != 116 {
+ t.Fatal("Forward API scope must contain 116 operations")
}
return fixture.Cases
}
@@ -173,6 +174,9 @@ func contractParams[T any](t *testing.T, id string) T {
}
}
v := reflect.ValueOf(¶ms).Elem()
+ if c.Auth == "sa_key" {
+ v.FieldByName("SAKey").SetString("contract-sa-key")
+ }
for _, p := range c.Parameters {
if p.Location != "path" {
setField(t, v, p.Location, p.Name, parameterValue(p))
@@ -233,7 +237,11 @@ func contractClient(t *testing.T, id string, status int) forward.Client {
if r.URL.EscapedPath() != "/api/v1/forward"+path {
t.Errorf("path: %s want %s", r.URL.EscapedPath(), path)
}
- if r.Header.Get("Authorization") != "Bearer secret-pat" {
+ wantAuth := "Bearer secret-pat"
+ if c.Auth == "sa_key" {
+ wantAuth = "Bearer contract-sa-key"
+ }
+ if r.Header.Get("Authorization") != wantAuth {
t.Error("missing bearer authentication")
}
for _, p := range c.Parameters {
diff --git a/forward/testdata/api-contracts.json b/forward/testdata/api-contracts.json
index 83c9b7d..9bf5338 100644
--- a/forward/testdata/api-contracts.json
+++ b/forward/testdata/api-contracts.json
@@ -3250,5 +3250,64 @@
"download": false,
"raw": false,
"empty": false
+ },
+ {
+ "operation_id": "createServiceAccountToken",
+ "entry": "ServiceAccountTokens",
+ "response": {
+ "type": "service_account_token",
+ "access_token": "sat-contract-secret",
+ "token_type": "Bearer",
+ "expires_in": 3600,
+ "expires_at": "2026-10-13T12:00:00Z",
+ "auth_token_id": "auth_contract",
+ "service_account_id": "sa_contract",
+ "credential_id": "sak_contract",
+ "subject_type": "admin"
+ },
+ "source": "https://docs.qoder.com/cloud-agents/api/forward/service-account-tokens/create",
+ "service": "ServiceAccountTokenService",
+ "name": "New",
+ "stream": false,
+ "download": false,
+ "raw": false,
+ "empty": false
+ },
+ {
+ "operation_id": "createIdentityServiceAccountToken",
+ "entry": "Identities.ServiceAccountTokens",
+ "response": {
+ "type": "service_account_token",
+ "access_token": "sat-contract-secret",
+ "token_type": "Bearer",
+ "expires_in": 3600,
+ "expires_at": "2026-10-13T12:00:00Z",
+ "auth_token_id": "auth_contract",
+ "service_account_id": "sa_contract",
+ "credential_id": "sak_contract",
+ "subject_type": "identity",
+ "identity_id": "idn_contract"
+ },
+ "source": "https://docs.qoder.com/cloud-agents/api/forward/service-account-tokens/create-identity",
+ "service": "IdentityServiceAccountTokenService",
+ "name": "New",
+ "stream": false,
+ "download": false,
+ "raw": false,
+ "empty": false
+ },
+ {
+ "operation_id": "revokeServiceAccountToken",
+ "entry": "ServiceAccountTokens",
+ "response": {
+ "revoked": true
+ },
+ "source": "https://docs.qoder.com/cloud-agents/api/forward/service-account-tokens/revoke",
+ "service": "ServiceAccountTokenService",
+ "name": "Revoke",
+ "stream": false,
+ "download": false,
+ "raw": false,
+ "empty": false
}
]
diff --git a/forward/testdata/api-operation-cases.json b/forward/testdata/api-operation-cases.json
index 9358fd7..5be5847 100644
--- a/forward/testdata/api-operation-cases.json
+++ b/forward/testdata/api-operation-cases.json
@@ -1,6 +1,6 @@
{
"schema_version": 1,
- "operation_count": 113,
+ "operation_count": 116,
"cases": [
{
"operation_id": "addSessionResource",
@@ -4924,6 +4924,70 @@
],
"request_body": null,
"source": "cloud-agent/cloud-agents-api-doc origin/master 1b3f3d023b8631395c3f65577a507050d3893d12 (2026-10-09)"
+ },
+ {
+ "operation_id": "createServiceAccountToken",
+ "http_method": "POST",
+ "path": "/service_account_tokens",
+ "auth": "sa_key",
+ "source": "https://docs.qoder.com/cloud-agents/api/forward/service-account-tokens/create",
+ "parameters": [],
+ "request_body": {
+ "kind": "json",
+ "value": {
+ "ttl_seconds": 3600,
+ "metadata": {
+ "purpose": "admin automation"
+ }
+ }
+ }
+ },
+ {
+ "operation_id": "createIdentityServiceAccountToken",
+ "http_method": "POST",
+ "path": "/identities/{identity_id}/service_account_tokens",
+ "auth": "sa_key",
+ "source": "https://docs.qoder.com/cloud-agents/api/forward/service-account-tokens/create-identity",
+ "parameters": [
+ {
+ "wire_name": "identity_id",
+ "location": "path",
+ "required": true,
+ "value": "idn_contract"
+ }
+ ],
+ "request_body": {
+ "kind": "json",
+ "value": {
+ "ttl_seconds": 3600,
+ "metadata": {
+ "purpose": "identity runtime"
+ }
+ }
+ }
+ },
+ {
+ "operation_id": "revokeServiceAccountToken",
+ "http_method": "POST",
+ "path": "/service_account_tokens/{auth_token_id}/revoke",
+ "auth": "sa_key",
+ "source": "https://docs.qoder.com/cloud-agents/api/forward/service-account-tokens/revoke",
+ "parameters": [
+ {
+ "wire_name": "auth_token_id",
+ "location": "path",
+ "required": true,
+ "value": "auth_contract"
+ }
+ ],
+ "request_body": {
+ "kind": "json",
+ "value": {
+ "target_credential_id": "sak_contract",
+ "target_expires_at": "2026-10-13T12:00:00Z",
+ "reason": "session ended"
+ }
+ }
}
]
}
diff --git a/integration/sa_tokens_live_test.go b/integration/sa_tokens_live_test.go
new file mode 100644
index 0000000..5324a1f
--- /dev/null
+++ b/integration/sa_tokens_live_test.go
@@ -0,0 +1,111 @@
+//go:build live
+
+package integration_test
+
+import (
+ "context"
+ "errors"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention/apierror"
+ "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention/option"
+ "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention/param"
+ "github.com/QoderAI/qoder-cloud-agents-sdk-go/forward"
+ "github.com/QoderAI/qoder-cloud-agents-sdk-go/internal/testsupport"
+)
+
+func saTokenDenied(t *testing.T, err error, statuses ...int) {
+ t.Helper()
+ var api *apierror.Error
+ if errors.As(err, &api) {
+ for _, status := range statuses {
+ if api.StatusCode == status {
+ return
+ }
+ }
+ }
+ t.Fatalf("expected access refusal: %s", testsupport.SafeError(err))
+}
+
+func saTokenMetadata(t *testing.T, token *forward.ServiceAccountToken, subject, identityID string) {
+ t.Helper()
+ if token == nil || token.AccessToken == "" || token.Type != "service_account_token" || token.TokenType != "Bearer" ||
+ token.AuthTokenID == "" || token.CredentialID == "" || token.ServiceAccountID == "" ||
+ token.SubjectType != subject || token.IdentityID != identityID || token.ExpiresIn <= 0 || token.ExpiresIn > 600 ||
+ !token.ExpiresAt.After(time.Now()) {
+ t.Fatal("issued SAT metadata did not match the requested subject and lifetime")
+ }
+ t.Logf("issued subject=%s auth_token_id=%s credential_id=%s identity_id=%s expires_at=%s", subject, token.AuthTokenID, token.CredentialID, token.IdentityID, token.ExpiresAt.UTC().Format(time.RFC3339))
+}
+
+func TestForwardServiceAccountTokensLive(t *testing.T) {
+ saRequireConfiguration(t, false)
+ if os.Getenv("QODER_SA_LIVE_ALLOW_WRITE") != "true" {
+ t.Fatal("QODER_SA_LIVE_ALLOW_WRITE=true is required")
+ }
+ for _, region := range []string{"cn", "global"} {
+ t.Run(region, func(t *testing.T) {
+ variable, host := "QODER_SA_GLOBAL_KEY", "https://api.qoder.com"
+ if region == "cn" {
+ variable, host = "QODER_SA_CN_KEY", "https://api.qoder.com.cn"
+ }
+ key := os.Getenv(variable)
+ opts := []option.RequestOption{option.WithBaseURL(host + "/api/v1/forward/"), option.WithAuthMode("sa"), option.WithMaxRetries(0), option.WithRequestTimeout(30 * time.Second), testsupport.RequestLog(t)}
+ issuer := forward.NewClient(opts...)
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
+ defer cancel()
+ params := forward.ServiceAccountTokenNewParams{SAKey: key, TTLSeconds: forward.Int(600), Metadata: map[string]any{"purpose": "sdk verification", "sdk": "go"}}
+ adminToken, err := issuer.ServiceAccountTokens.New(ctx, params)
+ saCheck(t, err)
+ admin := forward.NewClient(append(opts, option.WithAuthToken(adminToken.AccessToken))...)
+ revoke := func(ctx context.Context, token *forward.ServiceAccountToken) error {
+ result, err := issuer.ServiceAccountTokens.Revoke(ctx, token.AuthTokenID, forward.ServiceAccountTokenRevokeParams{SAKey: key, TargetCredentialID: forward.String(token.CredentialID), TargetExpiresAt: param.NewOpt(token.ExpiresAt), Reason: forward.String("sdk verification complete")})
+ if err == nil && (result == nil || !result.Revoked) {
+ return errors.New("revoke did not acknowledge success")
+ }
+ return err
+ }
+ saCleanup(t, "admin SAT "+adminToken.AuthTokenID, func(ctx context.Context) error {
+ if err := revoke(ctx, adminToken); err != nil {
+ return err
+ }
+ _, err := admin.Templates.List(ctx, forward.TemplateListParams{Limit: forward.Int(1)})
+ saTokenDenied(t, err, 401, 403)
+ return nil
+ })
+ saTokenMetadata(t, adminToken, "admin", "")
+ _, err = admin.Templates.List(ctx, forward.TemplateListParams{Limit: forward.Int(1)})
+ saCheck(t, err)
+ identities := make([]string, 0, 2)
+ for i := 0; i < 2; i++ {
+ identity, err := admin.Identities.New(ctx, forward.IdentityNewParams{ExternalID: "sdk-go-sat-" + testsupport.Marker(t)})
+ saCheck(t, err)
+ id := identity.ID
+ saCleanup(t, "identity "+id, func(ctx context.Context) error { _, err := admin.Identities.Delete(ctx, id); return err })
+ if id == "" {
+ t.Fatal("created Identity must have an ID")
+ }
+ identities = append(identities, id)
+ }
+ identityToken, err := issuer.Identities.ServiceAccountTokens.New(ctx, identities[0], params)
+ saCheck(t, err)
+ runtime := forward.NewClient(append(opts, option.WithAuthToken(identityToken.AccessToken))...)
+ saCleanup(t, "identity SAT "+identityToken.AuthTokenID, func(ctx context.Context) error {
+ if err := revoke(ctx, identityToken); err != nil {
+ return err
+ }
+ _, err := runtime.Identities.Configs.List(ctx, identities[0], forward.IdentityConfigListParams{Limit: forward.Int(1)})
+ saTokenDenied(t, err, 401, 403)
+ return nil
+ })
+ saTokenMetadata(t, identityToken, "identity", identities[0])
+ _, err = runtime.Identities.Configs.List(ctx, identities[0], forward.IdentityConfigListParams{Limit: forward.Int(1)})
+ saCheck(t, err)
+ _, err = runtime.Identities.Configs.List(ctx, identities[1], forward.IdentityConfigListParams{Limit: forward.Int(1)})
+ saTokenDenied(t, err, 403)
+ t.Log("verified admin business access, Identity business access and cross-Identity refusal")
+ })
+ }
+}
diff --git a/internal/testsupport/contracts.go b/internal/testsupport/contracts.go
index c23244c..94a0c80 100644
--- a/internal/testsupport/contracts.go
+++ b/internal/testsupport/contracts.go
@@ -158,6 +158,9 @@ func call(t *testing.T, method reflect.Value, ctx context.Context, emptyPath int
}
}
for j := 0; j < typ.NumField(); j++ {
+ if typ.Field(j).Name == "SAKey" && v.Field(j).Kind() == reflect.String {
+ v.Field(j).SetString("contract-sa-key")
+ }
if typ.Field(j).Tag.Get("path") != "" && v.Field(j).Kind() == reflect.String && emptyPath != 1000*i+j {
v.Field(j).SetString("segment /?%#")
}