Documentation
¶
Overview ¶
Package azure_ad authenticates Microsoft Entra ID (Azure AD) tokens. Composes the Phase 1 oidc.Provider (decision §9.2) for the heavy lifting — signature verify and base claim validation — and layers AAD-specific concerns on top:
- Tenant lock-in via the `tid` claim
- Optional Microsoft Graph group enrichment when the JWT's groups claim overflows (AAD truncates at ~200 groups)
- Correct issuer template for single- vs. multi-tenant
Decision §9.5: standard Bearer flow; no widened-header use.
Audit reason codes (Phase 1 contract):
rejected — bad signature, expired, tid mismatch,
aud mismatch, Graph 401/403
invalid — missing tid, malformed claims,
unsupported alg
provider_unavailable — AAD JWKS down, Graph 5xx
not_for_me — empty Bearer (delegates to OIDC's looksLikeJWT)
Index ¶
Constants ¶
const ProviderName = "azure_ad"
ProviderName is the registry name.
Variables ¶
This section is empty.
Functions ¶
func ExtractTenantID ¶
ExtractTenantID returns the "tid" claim, or "" if it's missing / non-string. The empty-return form lets callers distinguish "missing" from "wrong tenant" without a typed error.
Types ¶
type Config ¶
type Config struct {
// TenantID is the Entra tenant GUID. REQUIRED unless AllowMultiTenant
// is true.
TenantID string `yaml:"tenant_id"`
// Audience is REQUIRED. Typically the Application ID URI from the
// app registration (e.g. "api://forge").
Audience string `yaml:"audience"`
// AllowMultiTenant enables accepting tokens from Entra tenants other
// than the one in TenantID. Defaults to false (single-tenant — safe
// choice). When true:
// - the composed oidc.Provider's issuer-equality check is
// suppressed (the "common" issuer template has a {tenantid}
// placeholder that string-equality can't satisfy)
// - tenancy enforcement moves to AllowedTenants (below); see
// CHANGELOG for the security implications
AllowMultiTenant bool `yaml:"allow_multi_tenant,omitempty"`
// AllowedTenants is an optional allowlist of Entra tenant GUIDs,
// matched against the JWT's `tid` claim. Only meaningful when
// AllowMultiTenant=true; ignored in single-tenant mode (TenantID
// is the gate there).
//
// Empty list + AllowMultiTenant=true = "any tenant globally" —
// the documented but high-risk shape. Set this list for the safer
// "these specific tenants only" semantic.
//
// Effort to set: customers know their partner tenants; operators
// just copy GUIDs in. There is no API to enumerate them.
AllowedTenants []string `yaml:"allowed_tenants,omitempty"`
// GroupsMode is "claim" (default — uses the in-JWT groups/roles
// claim) or "graph" (queries Microsoft Graph when groups are missing,
// i.e. AAD overage).
GroupsMode string `yaml:"groups_mode,omitempty"`
// GraphTimeout caps each Graph call. Default 5s. Only used when
// GroupsMode == "graph".
GraphTimeout time.Duration `yaml:"graph_timeout,omitempty"`
// JWKSCacheTTL bounds the JWKS cache age. Defaults to 1h.
JWKSCacheTTL time.Duration `yaml:"jwks_cache_ttl,omitempty"`
// GraphEndpoint is a TEST-ONLY override pointing at a fake Graph
// server. Empty in production.
GraphEndpoint string `yaml:"-"`
}
Config controls the azure_ad provider.
type GraphCache ¶
type GraphCache struct {
// contains filtered or unexported fields
}
GraphCache holds enriched group memberships keyed by user ID, with a short TTL. Bounds how long a stale "removed from group" state stays cached after AAD's reality changes.
func NewGraphCache ¶
func NewGraphCache(ttl time.Duration) *GraphCache
NewGraphCache builds an empty cache.
func (*GraphCache) Get ¶
func (c *GraphCache) Get(userID string) ([]string, bool)
Get returns the cached groups for userID, or (nil, false) on miss/expiry.
The returned slice is a defensive copy — callers that subsequently mutate their Identity.Groups (the auth.Identity layer treats Groups as a freely- mutable field) MUST NOT corrupt the cache. (Review NIT.)
func (*GraphCache) Put ¶
func (c *GraphCache) Put(userID string, groups []string)
Put stores the groups under userID with a fresh TTL. Overwrites any prior entry (does not extend).
Stores a defensive copy so subsequent caller mutations of the input slice don't reach back through cache hits.
type GraphClient ¶
type GraphClient struct {
// contains filtered or unexported fields
}
GraphClient calls Microsoft Graph /me/transitiveMemberOf to enrich group memberships when the JWT's groups claim overflows (AAD truncates groups when the user is in more than ~200 of them).
Forge holds NO Graph credentials of its own — the caller's Bearer token is reflected to Graph, which authorizes the read against the user's delegated permission (GroupMember.Read.All).
func NewGraphClient ¶
func NewGraphClient(timeout time.Duration) *GraphClient
NewGraphClient builds a client pointed at the real Graph endpoint.
func NewGraphClientWithEndpoint ¶
func NewGraphClientWithEndpoint(endpoint string, timeout time.Duration) *GraphClient
NewGraphClientWithEndpoint is a TEST-ONLY constructor for pointing at a fake Graph server.
func (*GraphClient) TransitiveMemberOf ¶
func (c *GraphClient) TransitiveMemberOf(ctx context.Context, _ string, authHeader string) ([]string, error)
TransitiveMemberOf walks the paginated response and returns the full list of (transitive) group object IDs the caller belongs to. The authHeader is reflected verbatim — Forge does not authenticate to Graph independently.
Error classification:
401 / 403 → auth.ErrTokenRejected (caller's token missing
GroupMember.Read.All consent)
5xx / network → auth.ErrProviderUnavailable
@odata.nextLink pointing at a foreign host → error (never followed)