Skip to content

Latest commit

 

History

History
929 lines (673 loc) · 36.1 KB

File metadata and controls

929 lines (673 loc) · 36.1 KB

Contrib plugins reference

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 CredentialProvider and handle a single auth flow (OAuth2 client credentials, refresh token grant, Microsoft Secure Application Model).
  • Mux implements the full Plugin interface 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.


Mux

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.

NewMux

func NewMux(opts ...MuxOption) *Mux

Creates a new multiplexer. Pass zero or more MuxOption values to configure behavior.

MuxOption

type MuxOption func(*Mux)

WithLogger

func WithLogger(l *slog.Logger) MuxOption

Sets the logger for mux warnings (e.g., tie-breaking). Defaults to slog.Default().

Handle

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.

Default

func (m *Mux) Default(provider sdk.CredentialProvider)

Sets a fallback provider used when no registered route matches.

SetSigner

func (m *Mux) SetSigner(signer sdk.CertificateSigner)

Configures the CertificateSigner delegate. Without a signer, SignCSR returns an error.

SetResponseModifier

func (m *Mux) SetResponseModifier(modifier sdk.ResponseModifier)

Configures the ResponseModifier delegate. Without a modifier, ModifyResponse returns a nil action and nil error.

GetCredentials

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:

  1. All registered routes are tested against tx.
  2. The match with the highest specificity wins.
  3. 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).
  4. If no route matches, the default provider is used.
  5. If no route matches and no default is configured, returns ErrNoRouteMatch.

SignCSR

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.

ModifyResponse

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.


Route

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-*"

Matches

func (r Route) Matches(tx sdk.TransactionContext) bool

Reports whether every non-empty field in the route matches the corresponding tx field. TargetURL matching strips the URL scheme (e.g., https://) before comparison.

Specificity

func (r Route) Specificity() int

Returns 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

Glob patterns

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"

GlobMatch

func GlobMatch(pattern, input string, sep byte) bool

Package-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.


KeyResolver

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.

ResolveCredentialKey

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:

  1. If tx.Data[dataField] is present and is a valid non-empty string, return it (explicit connector override).
  2. If tx.Data[dataField] is present but has the wrong type or is empty, return ErrInvalidContextData. Malformed overrides never fall through to the resolver.
  3. If tx.Data[dataField] is absent and resolver is not nil, call resolver.ResolveKey. Resolver errors are wrapped with field context (e.g., "resolving TenantID: ..."). An empty string from the resolver returns ErrMissingContextData.
  4. If absent and resolver is nil, return ErrMissingContextData.

StaticMapping

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.

NewStaticMapping

func NewStaticMapping(rules []MappingRule, opts ...StaticMappingOption) *StaticMapping

Creates a StaticMapping from the given rules. Panics if any rule has an empty Key field (catches misconfiguration at startup).

StaticMappingOption

type StaticMappingOption func(*StaticMapping)

WithMappingLogger

func WithMappingLogger(l *slog.Logger) StaticMappingOption

Sets the logger for tie-breaking warnings. Defaults to slog.Default().

MappingRule

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).

Specificity

func (r MappingRule) Specificity() int

Returns 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

Catch-all rule

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.

Example

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,
})

OAuth2 client credentials

import "github.com/cloudblue/chaperone/plugins/contrib/oauth"

Implements the OAuth2 client credentials grant (RFC 6749 Section 4.4).

ClientCredentialsConfig

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.

AuthMode

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.

NewClientCredentials

func NewClientCredentials(cfg ClientCredentialsConfig) *ClientCredentials

Creates a new client credentials provider. Applies defaults for unset optional fields (HTTPClient, Logger, ExpiryMargin). Panics if TokenURL is empty (catches misconfiguration at startup).

ClientCredentials

type ClientCredentials struct{ /* unexported */ }

Implements CredentialProvider. Safe for concurrent use.

GetCredentials

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 GetCredentials at the same time.
  • ExpiresAt on the returned credential is expires_in minus the configured expiry margin.

OAuth2 refresh token

import "github.com/cloudblue/chaperone/plugins/contrib/oauth"

Implements the OAuth2 refresh token grant (RFC 6749 Section 6).

Getting started: The TokenStore must be seeded with an initial refresh token before the proxy can use this building block. Use the chaperone-onboard oauth CLI tool to perform the one-time consent flow.

RefreshTokenConfig

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.

NewRefreshToken

func NewRefreshToken(cfg RefreshTokenConfig) *RefreshToken

Creates 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).

RefreshToken

type RefreshToken struct{ /* unexported */ }

Implements CredentialProvider. Safe for concurrent use.

GetCredentials

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:

  1. Returns a cached access token if one exists and has not expired.
  2. On cache miss, loads the refresh token from the TokenStore.
  3. Exchanges the refresh token at the token endpoint for a new access token.
  4. If the response contains a rotated refresh token, saves it back to the store. A Save failure is logged at error level but does not fail the request — the access token is still valid for its TTL.
  5. Concurrent requests are deduplicated via singleflight.

OAuth TokenStore

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.

OAuth FileStore

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.

NewFileStore

func NewFileStore(path string) *FileStore

Creates 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.

Atomic writes

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.

Error behavior

Method Condition Error
Load File does not exist Wraps os.ErrNotExist (check with errors.Is)
Save Empty refreshToken Returns an error

Example

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.


Microsoft Secure Application Model

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 TokenStore must be seeded with an initial refresh token for each tenant before the proxy can use this building block. The tutorial covers seeding, or see chaperone-onboard microsoft for the standalone onboarding guide.

Config

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.

NewRefreshTokenSource

func NewRefreshTokenSource(cfg Config) *RefreshTokenSource

Creates 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.

RefreshTokenSource

type RefreshTokenSource struct{ /* unexported */ }

Implements CredentialProvider. Safe for concurrent use.

GetCredentials

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 the TokenStore — 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.

Microsoft TokenStore

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.

Microsoft FileStore

type FileStore struct{ /* unexported */ }

A file-backed TokenStore that stores one refresh token per tenant as a plain text file at baseDir/{tenantID}.

NewFileStore

func NewFileStore(baseDir string) *FileStore

Creates a FileStore rooted at baseDir. Panics if baseDir is empty.

Save creates the baseDir directory automatically if it doesn't exist.

File layout

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

Atomic writes

Same pattern as oauth.FileStore: temp file, fsync, rename. Files are created with 0600 permissions; directories with 0700.

Error behavior

Method Condition Error
Load File does not exist Wraps ErrTenantNotFound
Load / Save Invalid tenantID Validation error (not ErrTenantNotFound)
Save Empty refreshToken Returns an error

Example

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

Sentinel errors

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

Adapter

AsPlugin

func AsPlugin(provider sdk.CredentialProvider) sdk.Plugin

Wraps a CredentialProvider into a full Plugin with stub implementations:

  • GetCredentials delegates to the wrapped provider.
  • SignCSR returns an error ("certificate signing not configured").
  • ModifyResponse returns 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.


Examples

Mux with Microsoft and generic OAuth2

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)
}

Vault-backed TokenStore

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
}

Multiple Microsoft app registrations

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.