Reusable auth flow building blocks and a request multiplexer for the Chaperone egress proxy.
The contrib module is a separate Go module (github.com/cloudblue/chaperone/plugins/contrib) that depends on the SDK and provides two layers:
- Building blocks implement
CredentialProviderand handle a single auth flow (OAuth2 client credentials, refresh token grant, Microsoft Secure Application Model). - Mux implements the full
Plugininterface and routes requests to the right building block by vendor, environment, or target URL.
Sub-packages:
| Package | Import path | Purpose |
|---|---|---|
contrib |
github.com/cloudblue/chaperone/plugins/contrib |
Mux, Route, KeyResolver, errors, adapter |
contrib/oauth |
github.com/cloudblue/chaperone/plugins/contrib/oauth |
Generic OAuth2 grants |
contrib/microsoft |
github.com/cloudblue/chaperone/plugins/contrib/microsoft |
Microsoft Secure Application Model |
Go standard library types used in signatures link to official documentation: context.Context, *http.Request, *http.Response, time.Time, time.Duration, *slog.Logger.
type Mux struct{ /* unexported */ }A request multiplexer that dispatches to the most specific matching CredentialProvider based on transaction context fields. Mux implements Plugin and can be passed directly to chaperone.Run().
Safe for concurrent use after construction. Handle and Default are not safe for concurrent calls — register all routes before serving traffic.
func NewMux(opts ...MuxOption) *MuxCreates a new multiplexer. Pass zero or more MuxOption values to configure behavior.
type MuxOption func(*Mux)func WithLogger(l *slog.Logger) MuxOptionSets the logger for mux warnings (e.g., tie-breaking). Defaults to slog.Default().
func (m *Mux) Handle(route Route, provider sdk.CredentialProvider)Registers a route that dispatches matching requests to provider. Routes are evaluated by specificity at dispatch time. Registration order breaks ties.
func (m *Mux) Default(provider sdk.CredentialProvider)Sets a fallback provider used when no registered route matches.
func (m *Mux) SetSigner(signer sdk.CertificateSigner)Configures the CertificateSigner delegate. Without a signer, SignCSR returns an error.
func (m *Mux) SetResponseModifier(modifier sdk.ResponseModifier)Configures the ResponseModifier delegate. Without a modifier, ModifyResponse returns a nil action and nil error.
func (m *Mux) GetCredentials(ctx context.Context, tx sdk.TransactionContext, req *http.Request) (*sdk.Credential, error)Dispatches the request to the best matching route's provider:
- All registered routes are tested against
tx. - The match with the highest specificity wins.
- If multiple matches share the highest specificity, the first registered route wins. A warning is logged at registration time when potentially overlapping routes are detected (see
Handle). - If no route matches, the default provider is used.
- If no route matches and no default is configured, returns
ErrNoRouteMatch.
func (m *Mux) SignCSR(ctx context.Context, csrPEM []byte) ([]byte, error)Delegates to the configured signer. Returns an error if no signer has been set.
func (m *Mux) ModifyResponse(ctx context.Context, tx sdk.TransactionContext, resp *http.Response) (*sdk.ResponseAction, error)Delegates to the configured modifier. Returns a nil action and nil error if no modifier has been set.
type Route struct {
VendorID string
MarketplaceID string
ProductID string
TargetURL string
EnvironmentID string
}Matching criteria for dispatching requests. Each non-empty field must match the corresponding TransactionContext field. Empty fields act as wildcards. All fields support glob patterns.
| Field | Matches against | Example pattern |
|---|---|---|
VendorID |
tx.VendorID |
"microsoft-*" |
MarketplaceID |
tx.MarketplaceID |
"MP-*" |
ProductID |
tx.ProductID |
"MICROSOFT_*" |
TargetURL |
tx.TargetURL (scheme stripped) |
"*.graph.microsoft.com/**" |
EnvironmentID |
tx.EnvironmentID |
"prod-*" |
func (r Route) Matches(tx sdk.TransactionContext) boolReports whether every non-empty field in the route matches the corresponding tx field. TargetURL matching strips the URL scheme (e.g., https://) before comparison.
func (r Route) Specificity() intReturns the number of non-empty fields (0–5). The mux prefers routes with higher specificity when multiple routes match.
| Route | Specificity |
|---|---|
Route{} |
0 |
Route{VendorID: "acme"} |
1 |
Route{MarketplaceID: "MP-*", ProductID: "MICROSOFT_SAAS"} |
2 |
Route{EnvironmentID: "prod", VendorID: "acme", TargetURL: "api.acme.com/**"} |
3 |
Route fields use GlobMatch(pattern, input, sep) with / as the separator.
| Wildcard | Behavior | Example |
|---|---|---|
* |
Matches within one segment. Does not cross separators. | "microsoft-*" matches "microsoft-azure" but not "microsoft/azure" |
** |
Matches across segments. Crosses separators. | "*.graph.microsoft.com/**" matches "api.graph.microsoft.com/v1/users" |
func GlobMatch(pattern, input string, sep byte) boolPackage-level function. Tests whether input matches the glob pattern using sep as the segment separator. Route fields call this function internally with / as the separator.
type KeyResolver interface {
ResolveKey(ctx context.Context, tx sdk.TransactionContext) (string, error)
}Maps a TransactionContext to a credential key (e.g., a tenant ID, session name, or account identifier). Providers call ResolveKey when the request does not carry an explicit override in tx.Data.
A successful return must be a non-empty string. Return ErrMissingContextData if the transaction lacks enough information to resolve a key.
func ResolveCredentialKey(
ctx context.Context,
tx sdk.TransactionContext,
dataField string,
resolver KeyResolver,
) (string, error)Shared helper for credential-selection keys (for example, Microsoft TenantID). It implements a strict fallback chain:
- If
tx.Data[dataField]is present and is a valid non-empty string, return it (explicit connector override). - If
tx.Data[dataField]is present but has the wrong type or is empty, returnErrInvalidContextData. Malformed overrides never fall through to the resolver. - If
tx.Data[dataField]is absent andresolveris not nil, callresolver.ResolveKey. Resolver errors are wrapped with field context (e.g.,"resolving TenantID: ..."). An empty string from the resolver returnsErrMissingContextData. - If absent and
resolveris nil, returnErrMissingContextData.
type StaticMapping struct{ /* unexported */ }Implements KeyResolver with a declarative rule table. Each rule maps a pattern of transaction context fields to a credential key using glob patterns.
Rules are evaluated by specificity (most non-empty fields wins). When multiple rules match with equal specificity, the first registered rule wins. Potentially ambiguous rule pairs (equal specificity with overlapping patterns) are detected and warned about at construction time. If no rule matches, ResolveKey returns ErrNoMappingMatch — fail-closed by design.
Safe for concurrent use. Rules are set at construction time and only read during ResolveKey.
func NewStaticMapping(rules []MappingRule, opts ...StaticMappingOption) *StaticMappingCreates a StaticMapping from the given rules. Panics if any rule has an empty Key field (catches misconfiguration at startup).
type StaticMappingOption func(*StaticMapping)func WithMappingLogger(l *slog.Logger) StaticMappingOptionSets the logger for tie-breaking warnings. Defaults to slog.Default().
type MappingRule struct {
VendorID string // glob pattern
MarketplaceID string // glob pattern
EnvironmentID string // glob pattern
ProductID string // glob pattern
TargetURL string // glob pattern (scheme stripped before matching)
Key string // resolved credential key
}Each non-empty field must match the corresponding TransactionContext field. Empty fields act as wildcards. All fields support glob patterns with / as the separator. TargetURL strips the scheme before matching, same as Route.
| Field | Matches against | Example pattern |
|---|---|---|
VendorID |
tx.VendorID |
"acme" |
MarketplaceID |
tx.MarketplaceID |
"EU-*" |
EnvironmentID |
tx.EnvironmentID |
"prod" |
ProductID |
tx.ProductID |
"sku-*" |
TargetURL |
tx.TargetURL (scheme stripped) |
"*.graph.microsoft.com/**" |
Key |
— | The credential key returned on match. Required (panics if empty). |
func (r MappingRule) Specificity() intReturns the number of non-empty matching fields, excluding Key (range 0–5).
| Rule | Specificity |
|---|---|
MappingRule{Key: "default"} |
0 (catch-all) |
MappingRule{MarketplaceID: "EU-*", Key: "..."} |
1 |
MappingRule{MarketplaceID: "EU-*", VendorID: "acme", Key: "..."} |
2 |
MappingRule{MarketplaceID: "EU-*", VendorID: "acme", TargetURL: "*.graph.microsoft.com/**", Key: "..."} |
3 |
To provide a default key when no specific rule matches, add a rule with all matching fields empty:
contrib.MappingRule{Key: "default-tenant.onmicrosoft.com"}This has specificity 0 and matches any transaction context. Without a catch-all, unmatched requests return ErrNoMappingMatch.
resolver := contrib.NewStaticMapping([]contrib.MappingRule{
{MarketplaceID: "EU-*", Key: "contoso-eu.onmicrosoft.com"},
{MarketplaceID: "US-*", Key: "contoso-us.onmicrosoft.com"},
{MarketplaceID: "AP-*", Key: "contoso-ap.onmicrosoft.com"},
})
source := microsoft.NewRefreshTokenSource(microsoft.Config{
ClientID: os.Getenv("MS_CLIENT_ID"),
ClientSecret: os.Getenv("MS_CLIENT_SECRET"),
Store: store,
KeyResolver: resolver,
})import "github.com/cloudblue/chaperone/plugins/contrib/oauth"Implements the OAuth2 client credentials grant (RFC 6749 Section 4.4).
type ClientCredentialsConfig struct {
TokenURL string
ClientID string
ClientSecret string
Scopes []string
ExtraParams map[string]string
AuthMode AuthMode
HTTPClient *http.Client
Logger *slog.Logger
ExpiryMargin time.Duration
}| Field | Type | Default | Description |
|---|---|---|---|
TokenURL |
string |
— (required) | OAuth2 token endpoint URL. |
ClientID |
string |
— (required) | OAuth2 client identifier. |
ClientSecret |
string |
— (required) | OAuth2 client secret. |
Scopes |
[]string |
nil |
Scopes to request. Joined with space per RFC 6749. |
ExtraParams |
map[string]string |
nil |
Extra form parameters merged into the token request. Cannot override standard fields (grant_type, client_id, client_secret, scope). |
AuthMode |
AuthMode |
AuthModePost |
How credentials are sent to the token endpoint. |
HTTPClient |
*http.Client |
10s timeout, TLS 1.3+ | HTTP client for token requests. |
Logger |
*slog.Logger |
slog.Default() |
Logger for debug and warning messages. |
ExpiryMargin |
time.Duration |
1 minute | Subtracted from expires_in before setting ExpiresAt. |
type AuthMode int
const (
AuthModePost AuthMode = iota // client_secret_post (default)
AuthModeBasic // client_secret_basic
)| Value | Behavior |
|---|---|
AuthModePost |
Sends client_id and client_secret as form parameters in the POST body. |
AuthModeBasic |
Sends credentials via the Authorization: Basic header. |
func NewClientCredentials(cfg ClientCredentialsConfig) *ClientCredentialsCreates a new client credentials provider. Applies defaults for unset optional fields (HTTPClient, Logger, ExpiryMargin). Panics if TokenURL is empty (catches misconfiguration at startup).
type ClientCredentials struct{ /* unexported */ }Implements CredentialProvider. Safe for concurrent use.
func (cc *ClientCredentials) GetCredentials(ctx context.Context, _ sdk.TransactionContext, _ *http.Request) (*sdk.Credential, error)Fetches an OAuth2 bearer token and returns a cacheable Credential (Fast Path) with an Authorization: Bearer header.
Behavior:
- Returns a cached token if one exists and has not expired.
- On cache miss, fetches a new token from the token endpoint.
- Concurrent requests are deduplicated via singleflight — only one HTTP call is made regardless of how many goroutines call
GetCredentialsat the same time. ExpiresAton the returned credential isexpires_inminus the configured expiry margin.
import "github.com/cloudblue/chaperone/plugins/contrib/oauth"Implements the OAuth2 refresh token grant (RFC 6749 Section 6).
Getting started: The
TokenStoremust be seeded with an initial refresh token before the proxy can use this building block. Use thechaperone-onboard oauthCLI tool to perform the one-time consent flow.
type RefreshTokenConfig struct {
TokenURL string
ClientID string
ClientSecret string
Scopes []string
ExtraParams map[string]string
AuthMode AuthMode
Store TokenStore
HTTPClient *http.Client
Logger *slog.Logger
ExpiryMargin time.Duration
OnSaveError func(ctx context.Context, tokenURL string, err error)
}| Field | Type | Default | Description |
|---|---|---|---|
TokenURL |
string |
— (required) | OAuth2 token endpoint URL. |
ClientID |
string |
— (required) | OAuth2 client identifier. |
ClientSecret |
string |
— (required) | OAuth2 client secret. |
Scopes |
[]string |
nil |
Scopes to request. For v1-style endpoints, use ExtraParams with "resource" key instead. |
ExtraParams |
map[string]string |
nil |
Extra form parameters. Cannot override standard fields (grant_type, client_id, client_secret, scope, refresh_token). |
AuthMode |
AuthMode |
AuthModePost |
How credentials are sent to the token endpoint. |
Store |
TokenStore |
— (required) | Refresh token persistence. |
HTTPClient |
*http.Client |
10s timeout, TLS 1.3+ | HTTP client for token requests. |
Logger |
*slog.Logger |
slog.Default() |
Logger for debug, warning, and error messages. |
ExpiryMargin |
time.Duration |
1 minute | Subtracted from expires_in before setting ExpiresAt. |
OnSaveError |
func(ctx context.Context, tokenURL string, err error) |
nil |
Optional callback invoked when a rotated refresh token fails to persist. Use for metrics or alerting. The request still succeeds with the access token; only logging occurs if nil. |
func NewRefreshToken(cfg RefreshTokenConfig) *RefreshTokenCreates a new refresh token provider. Applies defaults for unset optional fields (HTTPClient, Logger, ExpiryMargin). Panics if TokenURL is empty or Store is nil (catches misconfiguration at startup).
type RefreshToken struct{ /* unexported */ }Implements CredentialProvider. Safe for concurrent use.
func (rt *RefreshToken) GetCredentials(ctx context.Context, _ sdk.TransactionContext, _ *http.Request) (*sdk.Credential, error)Fetches an OAuth2 bearer token using the refresh token grant and returns a cacheable Credential (Fast Path).
Behavior:
- Returns a cached access token if one exists and has not expired.
- On cache miss, loads the refresh token from the
TokenStore. - Exchanges the refresh token at the token endpoint for a new access token.
- If the response contains a rotated refresh token, saves it back to the store. A
Savefailure is logged at error level but does not fail the request — the access token is still valid for its TTL. - Concurrent requests are deduplicated via singleflight.
type TokenStore interface {
Load(ctx context.Context) (refreshToken string, err error)
Save(ctx context.Context, refreshToken string) error
}Abstracts refresh token persistence for a single session. Scoped to one token URL, one client, one refresh token.
| Method | Description |
|---|---|
Load |
Retrieves the current refresh token. |
Save |
Persists a rotated refresh token after a successful exchange. |
Implementations must be safe for concurrent use and should be durable. A failed Save after a successful exchange means the rotated refresh token is lost — the old one has been invalidated by the token endpoint.
type FileStore struct{ /* unexported */ }A file-backed TokenStore that reads and writes a single refresh token to a plain text file. The token is stored as raw text with no wrapper or metadata.
func NewFileStore(path string) *FileStoreCreates a FileStore that persists the refresh token at path. Panics if path is empty.
Save creates parent directories automatically, so the file does not need to exist before the first write.
Writes use a temp-file-and-rename pattern: the token is written to a temporary file in the same directory, fsynced to disk, and renamed to the target path. This prevents corruption from a crash mid-write. Files are created with 0600 permissions; directories with 0700.
| Method | Condition | Error |
|---|---|---|
Load |
File does not exist | Wraps os.ErrNotExist (check with errors.Is) |
Save |
Empty refreshToken |
Returns an error |
store := oauth.NewFileStore("/var/lib/chaperone/refresh-token.txt")
provider := oauth.NewRefreshToken(oauth.RefreshTokenConfig{
TokenURL: "https://auth.vendor.com/oauth/token",
ClientID: os.Getenv("CLIENT_ID"),
ClientSecret: os.Getenv("CLIENT_SECRET"),
Store: store,
})Seed the file with the initial token from chaperone-onboard oauth. The proxy rotates it automatically from there.
import "github.com/cloudblue/chaperone/plugins/contrib/microsoft"Implements the delegated refresh token grant for Microsoft Partner Center. A single Azure AD app registration (one ClientID + ClientSecret) is shared across all tenants. Per-tenant state is managed by a keyed TokenStore.
Getting started: For a step-by-step walkthrough that covers project setup, onboarding, and wiring the Mux, see the Microsoft SAM with Mux tutorial.
The
TokenStoremust be seeded with an initial refresh token for each tenant before the proxy can use this building block. The tutorial covers seeding, or seechaperone-onboard microsoftfor the standalone onboarding guide.
type Config struct {
TokenEndpoint string
ClientID string
ClientSecret string
Store TokenStore
MaxPoolSize int
ExpiryMargin time.Duration
HTTPClient *http.Client
Logger *slog.Logger
KeyResolver contrib.KeyResolver
OnSaveError func(ctx context.Context, tenantID, resource string, err error)
}| Field | Type | Default | Description |
|---|---|---|---|
TokenEndpoint |
string |
"https://login.microsoftonline.com" |
Base URL for the Microsoft token service. Override for sovereign clouds (e.g., "https://login.microsoftonline.us"). |
ClientID |
string |
— (required) | Azure AD application (client) ID. |
ClientSecret |
string |
— (required) | Azure AD application secret. |
Store |
TokenStore |
— (required) | Per-tenant refresh token persistence. One refresh token per tenant (MRRT model). |
MaxPoolSize |
int |
10,000 |
Maximum per-tenant entries in the LRU pool. |
ExpiryMargin |
time.Duration |
5 minutes | Subtracted from expires_in before setting ExpiresAt. Matches the Python connector's 300-second margin. |
HTTPClient |
*http.Client |
10s timeout, TLS 1.3+ | HTTP client for token requests. |
Logger |
*slog.Logger |
slog.Default() |
Logger for debug, warning, and error messages. |
KeyResolver |
KeyResolver |
nil |
Resolves the tenant ID from transaction context when TenantID is not present in tx.Data. If nil, TenantID must be present in tx.Data; otherwise GetCredentials returns ErrMissingContextData. See StaticMapping for the built-in rule-based implementation. |
OnSaveError |
func(ctx context.Context, tenantID, resource string, err error) |
nil |
Optional callback invoked when a rotated refresh token fails to persist. Use for metrics or alerting. The request still succeeds with the access token; only logging occurs if nil. |
func NewRefreshTokenSource(cfg Config) *RefreshTokenSourceCreates a new Microsoft refresh token source. Applies defaults for unset optional fields (TokenEndpoint, MaxPoolSize, ExpiryMargin, HTTPClient, Logger). Does not validate required fields at construction time — a missing ClientID, ClientSecret, or Store causes errors at first GetCredentials call.
type RefreshTokenSource struct{ /* unexported */ }Implements CredentialProvider. Safe for concurrent use.
func (s *RefreshTokenSource) GetCredentials(ctx context.Context, tx sdk.TransactionContext, req *http.Request) (*sdk.Credential, error)Resolves TenantID and Resource from the transaction context and returns a cacheable Credential (Fast Path).
Context data contract:
| Key | Type | Resolution | Description |
|---|---|---|---|
"TenantID" |
string |
tx.Data override → KeyResolver → error |
Azure AD tenant (e.g., "contoso.onmicrosoft.com"). Implemented via ResolveCredentialKey. If present in tx.Data, that value is used (connector override). If absent, the configured KeyResolver is called. If no resolver is configured, returns ErrMissingContextData. Malformed overrides (wrong type, empty) return ErrInvalidContextData and never fall through to the resolver. The resolved value must match ^[a-zA-Z0-9][a-zA-Z0-9.\-]*$ regardless of source. |
"Resource" |
string |
tx.Data only |
Target resource (e.g., "https://graph.microsoft.com"). Implemented via DataString plus explicit missing-field handling in the provider. Always required in tx.Data — no resolver fallback. This is a per-request concern (which API the connector is calling). Returns ErrMissingContextData if absent, ErrInvalidContextData if not a string or empty. |
Token endpoint URL construction:
{TokenEndpoint}/{TenantID}/oauth2/token
Example: https://login.microsoftonline.com/contoso.onmicrosoft.com/oauth2/token
The resource parameter is sent as a form parameter (v1 style), not as a v2 scope.
MRRT model:
Azure AD refresh tokens are Multi-Resource Refresh Tokens (MRRTs): a single refresh token per tenant can be exchanged for access tokens to any consented resource. The RefreshTokenSource stores one refresh token per tenant and caches access tokens per (tenant, resource) pair.
Tenant pool:
Each unique TenantID gets a per-tenant entry with a per-resource access token cache and a singleflight group. Entries are kept in a bounded LRU pool:
- On access, the entry moves to the front.
- When the pool reaches
MaxPoolSize, the least recently used entry is evicted. The refresh token remains safe in theTokenStore— only the in-memory access token caches are lost. - Concurrent requests for the same (tenant, resource) are deduplicated via singleflight. Concurrent requests for different resources on the same tenant run in parallel.
type TokenStore interface {
Load(ctx context.Context, tenantID string) (refreshToken string, err error)
Save(ctx context.Context, tenantID string, refreshToken string) error
}Multi-tenant refresh token persistence keyed by tenant only. Because Azure AD refresh tokens are MRRTs (Multi-Resource Refresh Tokens), a single refresh token per tenant suffices for all resources.
| Method | Parameters | Description |
|---|---|---|
Load |
tenantID |
Retrieves the current refresh token for this tenant. Returns ErrTenantNotFound if no token exists. |
Save |
tenantID, refreshToken |
Persists a rotated refresh token after a successful exchange. |
Implementations must be safe for concurrent use and should be durable. A failed Save after a successful exchange means the rotated token may be lost if the old one has been invalidated.
type FileStore struct{ /* unexported */ }A file-backed TokenStore that stores one refresh token per tenant as a plain text file at baseDir/{tenantID}.
func NewFileStore(baseDir string) *FileStoreCreates a FileStore rooted at baseDir. Panics if baseDir is empty.
Save creates the baseDir directory automatically if it doesn't exist.
Each tenant gets a single file directly under baseDir. The tenantID is used as the filename — it is validated against ^[a-zA-Z0-9][a-zA-Z0-9.\-]*$ to prevent path traversal.
baseDir/
contoso.onmicrosoft.com ← one MRRT for all resources
fabrikam.onmicrosoft.com
Same pattern as oauth.FileStore: temp file, fsync, rename. Files are created with 0600 permissions; directories with 0700.
| Method | Condition | Error |
|---|---|---|
Load |
File does not exist | Wraps ErrTenantNotFound |
Load / Save |
Invalid tenantID |
Validation error (not ErrTenantNotFound) |
Save |
Empty refreshToken |
Returns an error |
store := microsoft.NewFileStore("/var/lib/chaperone/tokens")
source := microsoft.NewRefreshTokenSource(microsoft.Config{
ClientID: os.Getenv("MS_CLIENT_ID"),
ClientSecret: os.Getenv("MS_CLIENT_SECRET"),
Store: store,
})Seed each tenant with chaperone-onboard microsoft. The resulting file layout:
/var/lib/chaperone/tokens/
contoso.onmicrosoft.com
fabrikam.onmicrosoft.com
All errors are defined in the contrib package and can be checked with errors.Is.
import "github.com/cloudblue/chaperone/plugins/contrib"| Error | Value | Cause | Retryable |
|---|---|---|---|
ErrNoRouteMatch |
"no route matched" |
No mux route matched and no default is configured. Proxy configuration issue. | No |
ErrNoMappingMatch |
"no mapping rule matched" |
No StaticMapping rule matched the transaction context. Fail-closed by design — add a catch-all rule if a default key is desired. Proxy configuration issue. |
No |
ErrMissingContextData |
"missing required context data" |
Required key (TenantID, Resource) absent from tx.Data and no resolver is configured, or resolver returned an empty string. Platform/caller issue. |
No |
ErrInvalidContextData |
"invalid context data type" |
Required key present but has wrong type (e.g., number instead of string), is an empty string, or contains invalid characters (TenantID must match ^[a-zA-Z0-9][a-zA-Z0-9.\-]*$). Platform/caller issue. |
No |
ErrTenantNotFound |
"tenant not found" |
Tenant not in store or resolver. Proxy configuration issue. | No |
ErrInvalidCredentials |
"invalid client credentials" |
OAuth2 token endpoint returned HTTP 401. Client secret is wrong or expired. | No |
ErrTokenExpiredOnArrival |
"token expired on arrival" |
Token expires_in is less than or equal to the expiry margin. Token too short-lived to cache. |
No |
ErrSigningNotConfigured |
"certificate signing not configured" |
SignCSR called on AsPlugin or Mux with no signer configured. |
No |
ErrTokenEndpointUnavailable |
"token endpoint unavailable" |
Network error, HTTP 5xx, or HTTP 429 from the token endpoint. | Yes |
func AsPlugin(provider sdk.CredentialProvider) sdk.PluginWraps a CredentialProvider into a full Plugin with stub implementations:
GetCredentialsdelegates to the wrapped provider.SignCSRreturns an error ("certificate signing not configured").ModifyResponsereturns a nil action and nil error (no-op).
Use this to pass a building block to the compliance test kit:
provider := oauth.NewClientCredentials(cfg)
compliance.VerifyContract(t, contrib.AsPlugin(provider))The Mux implements Plugin directly and does not need this adapter.
package main
import (
"context"
"os"
"github.com/cloudblue/chaperone"
"github.com/cloudblue/chaperone/plugins/contrib"
"github.com/cloudblue/chaperone/plugins/contrib/microsoft"
"github.com/cloudblue/chaperone/plugins/contrib/oauth"
)
func main() {
mux := contrib.NewMux()
// Microsoft vendors via Secure Application Model.
// StaticMapping resolves TenantID from marketplace when not in tx.Data.
msStore := microsoft.NewFileStore("/var/lib/chaperone/tokens")
mux.Handle(
contrib.Route{VendorID: "microsoft-*"},
microsoft.NewRefreshTokenSource(microsoft.Config{
ClientID: os.Getenv("MS_CLIENT_ID"),
ClientSecret: os.Getenv("MS_CLIENT_SECRET"),
Store: msStore,
KeyResolver: contrib.NewStaticMapping([]contrib.MappingRule{
{MarketplaceID: "MP-12345", Key: "contoso-eu.onmicrosoft.com"},
{MarketplaceID: "MP-67890", Key: "contoso-us.onmicrosoft.com"},
}),
}),
)
// Generic OAuth2 vendor
mux.Handle(
contrib.Route{VendorID: "acme"},
oauth.NewClientCredentials(oauth.ClientCredentialsConfig{
TokenURL: "https://auth.acme.com/oauth/token",
ClientID: os.Getenv("ACME_CLIENT_ID"),
ClientSecret: os.Getenv("ACME_CLIENT_SECRET"),
Scopes: []string{"api.read", "api.write"},
}),
)
// Fallback for unmatched vendors (your CredentialProvider here)
// mux.Default(fallbackProvider)
ctx := context.Background()
chaperone.Run(ctx, mux)
}Skeleton implementation of microsoft.TokenStore using HashiCorp Vault KV v2:
type VaultTokenStore struct {
client *vault.Client
mount string
}
func (v *VaultTokenStore) Load(ctx context.Context, tenantID string) (string, error) {
path := fmt.Sprintf("tenants/%s", tenantID)
secret, err := v.client.KVv2(v.mount).Get(ctx, path)
if err != nil {
return "", fmt.Errorf("vault read %s: %w", path, err)
}
token, ok := secret.Data["refresh_token"].(string)
if !ok {
return "", contrib.ErrTenantNotFound
}
return token, nil
}
func (v *VaultTokenStore) Save(ctx context.Context, tenantID, refreshToken string) error {
path := fmt.Sprintf("tenants/%s", tenantID)
_, err := v.client.KVv2(v.mount).Put(ctx, path, map[string]any{
"refresh_token": refreshToken,
})
if err != nil {
return fmt.Errorf("vault write %s: %w", path, err)
}
return nil
}When different groups of tenants require separate Azure AD app registrations — for example, one per region or partner program — create a RefreshTokenSource per app and route them through the Mux. All sources can share a single FileStore because tokens are keyed by tenant, not by app registration.
package main
import (
"context"
"os"
"github.com/cloudblue/chaperone"
"github.com/cloudblue/chaperone/plugins/contrib"
"github.com/cloudblue/chaperone/plugins/contrib/microsoft"
)
func main() {
// Shared token store — keyed by tenant ID, not by app registration.
store := microsoft.NewFileStore("/var/lib/chaperone/tokens")
// App registration A: serves EU marketplaces.
sourceA := microsoft.NewRefreshTokenSource(microsoft.Config{
ClientID: os.Getenv("APP_A_CLIENT_ID"),
ClientSecret: os.Getenv("APP_A_CLIENT_SECRET"),
Store: store,
KeyResolver: contrib.NewStaticMapping([]contrib.MappingRule{
{MarketplaceID: "MP-12345", Key: "contoso-de.onmicrosoft.com"},
{MarketplaceID: "MP-23456", Key: "contoso-ch.onmicrosoft.com"},
}),
})
// App registration B: serves US marketplaces.
sourceB := microsoft.NewRefreshTokenSource(microsoft.Config{
ClientID: os.Getenv("APP_B_CLIENT_ID"),
ClientSecret: os.Getenv("APP_B_CLIENT_SECRET"),
Store: store,
KeyResolver: contrib.NewStaticMapping([]contrib.MappingRule{
{MarketplaceID: "MP-34567", Key: "contoso-us.onmicrosoft.com"},
}),
})
mux := contrib.NewMux()
mux.Handle(contrib.Route{MarketplaceID: "MP-12345"}, sourceA)
mux.Handle(contrib.Route{MarketplaceID: "MP-23456"}, sourceA)
mux.Handle(contrib.Route{MarketplaceID: "MP-34567"}, sourceB)
ctx := context.Background()
chaperone.Run(ctx, mux)
}The token directory holds one file per tenant regardless of which app registration manages it:
/var/lib/chaperone/tokens/
contoso-de.onmicrosoft.com
contoso-ch.onmicrosoft.com
contoso-us.onmicrosoft.com
Seed each tenant with chaperone-onboard microsoft using the corresponding app registration's credentials, or write existing refresh tokens to the file directly.