sdk

package
v0.1.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 23, 2026 License: Apache-2.0 Imports: 14 Imported by: 0

README

Legant resource-server SDK

github.com/legant-dev/legant/sdk

A self-contained client for resource servers and MCP servers that accept Legant delegation tokens. It verifies a composite sub/act token against the issuer's published keys and authorizes a request's scope and constraints — entirely offline, with no callback to Legant and no dependency on Legant's internal packages. Its only dependency is golang-jwt.

Install

go get github.com/legant-dev/legant/sdk

Use

package main

import (
	"context"
	"net/http"
	"strings"

	"github.com/legant-dev/legant/sdk"
)

func main() {
	ctx := context.Background()

	// Fetch the issuer's signing keys once (refresh periodically in production).
	keys, err := sdk.FetchJWKS(ctx, "https://auth.example.com/.well-known/jwks.json")
	if err != nil {
		panic(err)
	}
	// audience MUST be this resource server's own identifier.
	v := sdk.NewVerifier("https://auth.example.com", "https://finance-api.example/", keys)

	http.HandleFunc("/expenses", func(w http.ResponseWriter, r *http.Request) {
		tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")

		// Verify: RS256 under the token's kid, plus issuer, audience, and expiry.
		// Fails closed on an unknown kid and requires an act chain (a delegation
		// token, not a plain access token).
		claims, err := v.Verify(tok)
		if err != nil {
			http.Error(w, "unauthorized", http.StatusUnauthorized)
			return
		}

		// Authorize the concrete action: scope plus every constraint the token
		// carries (max amount, category/tool/resource allow-lists).
		if err := claims.Authorize(sdk.Action{Scope: "expenses:submit", Amount: 120, Category: "travel"}); err != nil {
			http.Error(w, "forbidden: "+err.Error(), http.StatusForbidden)
			return
		}

		// claims.Provenance() == "user:alice -> agent:assistant"
		w.Write([]byte("ok, acting for " + claims.Provenance()))
	})

	http.ListenAndServe(":9000", nil)
}

What Verify checks

Check Behavior
Algorithm RS256 only (no none, no alg confusion)
Key Selected by the token's kid; unknown kid fails closed
Issuer Must equal the configured issuer
Audience Must contain this resource server's audience
Expiry Required and enforced
Delegation A non-nil act claim is required

Authorize then enforces the required scope and the cnst constraints. Together they are the resource server's policy decision point — no network call needed.

Static keys

If you distribute keys out of band instead of fetching JWKS, build the verifier directly:

v := sdk.NewVerifier(issuer, audience, map[string]*rsa.PublicKey{kid: pub})

Drift protection

The SDK intentionally re-implements the minimal claim shape rather than importing Legant's internals, so external consumers get a tiny dependency surface. A compatibility test (sdk/verifier_test.go) mints tokens with Legant's real internal signer and verifies them through this SDK, failing if the wire format ever diverges.

Documentation

Overview

Package sdk is a self-contained client for resource servers (and MCP servers) that accept Legant delegation tokens. It verifies a composite sub/act token against the issuer's published keys and authorizes a request's scope and constraints — entirely offline, with no callback to Legant and no dependency on Legant's internal packages. Its only dependency is golang-jwt.

Typical use at a resource server:

keys, _ := sdk.FetchJWKS(ctx, "https://legant.example/.well-known/jwks.json")
v := sdk.NewVerifier("https://legant.example", "https://my-api.example/", keys)
claims, err := v.Verify(bearerToken)
if err != nil { /* 401 */ }
if err := claims.Authorize(sdk.Action{Scope: "expenses:submit", Amount: 120, Category: "travel"}); err != nil {
    /* 403 */
}
// claims.Provenance() == "user:alice -> agent:assistant"

Index

Constants

This section is empty.

Variables

View Source
var ErrRevoked = errors.New("token revoked")

ErrRevoked is returned by Verify when the token's id is present in the configured revocation feed.

Functions

func Authenticate

func Authenticate(v *Verifier) func(http.Handler) http.Handler

Authenticate verifies the request's Bearer token with v and, on success, stores the Claims in the request context for downstream handlers (read them with ClaimsFrom). On any verification failure it writes 401 with an RFC 6750 WWW-Authenticate challenge and does NOT call the next handler. It does not authorize a specific action — compose RequireScope / RequireAction for that, or call Claims.Authorize inside the handler.

Revocation behavior follows how v was built: WithRevocationFeed makes a revoked token 401 here; WithFeedFailClosed couples availability to the feed. Without a feed, revocation is bounded by the token's TTL. Choose deliberately.

func FetchJWKS

func FetchJWKS(ctx context.Context, jwksURL string) (map[string]*rsa.PublicKey, error)

FetchJWKS fetches and parses an issuer's JSON Web Key Set into a kid->key map. The jwksURL is expected to be the resource server's own trusted issuer (a configured constant), not a value derived from request input — this is a startup/refresh call, not a per-request fetch, so it deliberately does not impose SSRF allow-listing. Pass a URL you control.

func MCPToolName

func MCPToolName(body []byte) (string, error)

MCPToolName extracts the tool name from a JSON-RPC MCP "tools/call" request body, for resource servers that ARE an MCP server (rather than sitting behind the gateway). Pair it with Claims.Authorize to gate a tool before dispatch:

name, _ := sdk.MCPToolName(body)
if err := claims.Authorize(sdk.Action{Scope: toolScopes[name], Tool: name}); err != nil { ... }

func ParseJWKS

func ParseJWKS(data []byte) (map[string]*rsa.PublicKey, error)

ParseJWKS parses a JWKS document into a kid->key map (RSA keys only).

func RequireAction

func RequireAction(action func(*http.Request) Action) func(http.Handler) http.Handler

RequireAction derives an Action from each request and authorizes it against the verified token, writing 403 on denial. Mount it AFTER Authenticate.

r.With(sdk.Authenticate(v), sdk.RequireAction(func(r *http.Request) sdk.Action {
    return sdk.Action{Scope: "warehouse:query", Resource: r.URL.Query().Get("schema")}
})).Get("/query", handler)

func RequireScope

func RequireScope(scope string) func(http.Handler) http.Handler

RequireScope authorizes that the request's token carries scope. Mount it AFTER Authenticate. For per-request constraints (amount/resource/tool/time), use RequireAction instead, or call Claims.Authorize in the handler.

Types

type Action

type Action struct {
	Scope    string
	Amount   float64
	Category string
	Tool     string
	Resource string
	At       time.Time // instant of the action; zero means "now" (time-window check)
}

Action describes the concrete operation being attempted. Zero-value fields are "not applicable" and skip the corresponding constraint check.

type Actor

type Actor struct {
	Sub string `json:"sub"`
	Act *Actor `json:"act,omitempty"`
}

Actor models the RFC 8693 "act" claim: the most recent actor on top, earlier actors nested inside.

type Claims

type Claims struct {
	jwt.RegisteredClaims
	Scope       string       `json:"scope"`
	Act         *Actor       `json:"act,omitempty"`
	Constraints *Constraints `json:"cnst,omitempty"`
}

Claims is the verified body of a delegation token.

func ClaimsFrom

func ClaimsFrom(ctx context.Context) (*Claims, bool)

ClaimsFrom returns the verified Claims stored by Authenticate, if any.

func MustClaims

func MustClaims(ctx context.Context) *Claims

MustClaims returns the verified Claims or panics — use only inside handlers mounted behind Authenticate (where the claims are guaranteed present).

func (*Claims) Authorize

func (c *Claims) Authorize(a Action) error

Authorize enforces that the token carries the required scope and that the action satisfies every constraint.

func (*Claims) Provenance

func (c *Claims) Provenance() string

Provenance renders the delegation path, e.g. "user:alice -> agent:assistant -> agent:ocr".

type Constraints

type Constraints struct {
	MaxAmount  *float64    `json:"max_amount,omitempty"`
	Categories []string    `json:"categories,omitempty"`
	Tools      []string    `json:"tools,omitempty"`
	Resources  []string    `json:"resources,omitempty"`
	TimeWindow *TimeWindow `json:"time_window,omitempty"`
	// Rate is informational at the resource server: a rolling-hour cap needs shared
	// state and is enforced by Legant at token-exchange time, not offline here.
	Rate *RateLimit `json:"rate,omitempty"`
}

Constraints are the fine-grained limits carried in the token's "cnst" claim.

type Option

type Option func(*Verifier)

Option configures an optional Verifier behavior.

func WithFeedFailClosed

func WithFeedFailClosed(maxStaleness time.Duration) Option

WithFeedFailClosed makes Verify REJECT tokens when the revocation feed is older than maxStaleness (high-assurance: couples availability to the feed). The default is fail-open-to-TTL: a stale/unreachable feed reverts to TTL-bounded revocation rather than rejecting valid tokens.

func WithRevocationFeed

func WithRevocationFeed(f *RevocationFeed) Option

WithRevocationFeed makes the verifier reject tokens present in the revocation feed (Tier B). The feed is pulled out of band (no per-request callback); a token revoked at the issuer is rejected here within the feed's refresh window. Without this option the verifier behaves exactly as before (revocation bounded by the token's short TTL — Tier C).

type RateLimit

type RateLimit struct {
	MaxPerHour int `json:"max_per_hour"`
}

RateLimit caps how often a delegation may be exercised per rolling hour.

type RevocationFeed

type RevocationFeed struct {
	// contains filtered or unexported fields
}

RevocationFeed is an offline, pull-based view of revoked tokens. The resource server fetches a signed feed from the issuer on a timer and checks token ids against an in-memory set — with NO per-request callback. A token revoked at the issuer takes effect here within the feed's refresh interval (and never later than the token's own short expiry, which is the always-present backstop).

The feed is signed with the SAME key as the issuer's JWKS, so it adds no new trust root. A stale or missing feed can only ever MISS a revocation, never invent one; and a regressing version is rejected as a rollback/replay.

func FetchRevocationFeed

func FetchRevocationFeed(ctx context.Context, feedURL, issuer string, keysByKID map[string]*rsa.PublicKey) (*RevocationFeed, error)

FetchRevocationFeed fetches and verifies the issuer's revocation feed once. keysByKID is the same JWKS key map the Verifier uses, so the feed's signature is checked under the established trust root.

func ParseRevocationFeed added in v0.1.1

func ParseRevocationFeed(feedJWT []byte, issuer string, keysByKID map[string]*rsa.PublicKey) (*RevocationFeed, error)

ParseRevocationFeed verifies a signed revocation feed read from bytes (for example a local feed.jwt file written by `legant apply` / `legant revoke`), with no HTTP. It is the offline counterpart of FetchRevocationFeed: same signature and version checks, no network. To pick up later revocations, parse the file again. keysByKID is the same JWKS key map the Verifier uses.

func (*RevocationFeed) IsRevoked

func (f *RevocationFeed) IsRevoked(jti string) bool

IsRevoked reports whether a token id is in the latest feed snapshot.

func (*RevocationFeed) Refresh

func (f *RevocationFeed) Refresh(ctx context.Context) error

Refresh fetches the feed and applies it (verify signature, enforce a monotonic version, atomically swap the in-memory set).

func (*RevocationFeed) Staleness

func (f *RevocationFeed) Staleness() time.Duration

Staleness is how long since the feed was last successfully refreshed.

func (*RevocationFeed) StartPolling

func (f *RevocationFeed) StartPolling(ctx context.Context, interval time.Duration, onError func(error))

StartPolling refreshes the feed on the given interval until ctx is cancelled. Refresh errors are non-fatal (the previous snapshot is retained); pass a logger-wrapped onError if you want visibility.

type TimeWindow

type TimeWindow struct {
	Weekdays []int  `json:"weekdays,omitempty"`
	StartMin int    `json:"start_min"`
	EndMin   int    `json:"end_min"`
	TZ       string `json:"tz,omitempty"`
}

TimeWindow restricts when the authority may be used: an optional weekday allow-list (0=Sunday … 6=Saturday) and an inclusive minute-of-day range, evaluated in TZ (IANA name; empty = UTC). Enforced offline.

func (*TimeWindow) Allows

func (w *TimeWindow) Allows(at time.Time) bool

Allows reports whether the instant at falls inside the window; an unknown TZ fails closed.

type Verifier

type Verifier struct {
	// contains filtered or unexported fields
}

Verifier verifies delegation tokens against a fixed issuer and audience.

func NewVerifier

func NewVerifier(issuer, audience string, keysByKID map[string]*rsa.PublicKey, opts ...Option) *Verifier

NewVerifier builds a verifier from public keys indexed by kid (e.g. from FetchJWKS). The audience must be this resource server's own identifier. It is compared against the token's aud after RFC 8707 canonicalization, so it is insensitive to host case, a default port (:443/:80), and a trailing slash — "https://api.example", "https://API.example:443", and "https://api.example/" all match the canonical "https://api.example/" the issuer mints.

func (*Verifier) Verify

func (v *Verifier) Verify(token string) (*Claims, error)

Verify validates a token: RS256 signature under the key named by its kid, plus issuer, audience, and expiry. It requires an act claim (a delegation token, not a plain access token) and fails closed on an unknown kid.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL