if props.ShowClientCredentials {
At least one grant type must be selected. Client Credentials Flow requires a confidential client.
@@ -394,6 +409,34 @@ templ ClientFormCommonFields(props ClientFormFieldsProps) {
}
+ if props.ShowIDJAG {
+
+
+
+ if props.Client != nil {
+
+ } else {
+
+ }
+
+
+
+
+
+ Restricts which trusted IdP iss values this client may present an ID-JAG from.
+ Each entry must also appear in the server's TRUSTED_IDPS. Leave empty to accept
+ any trusted IdP. Press Enter to add; paste multiple separated by commas or newlines.
+
+
+ }
diff --git a/internal/templates/props.go b/internal/templates/props.go
index 4be3776b..4b177c5d 100644
--- a/internal/templates/props.go
+++ b/internal/templates/props.go
@@ -222,6 +222,8 @@ type ClientDisplay struct {
EnableDeviceFlow bool
EnableAuthCodeFlow bool
EnableClientCredentialsFlow bool
+ EnableIDJAGFlow bool // Enterprise-Managed Authorization (ID-JAG) jwt-bearer grant
+ AllowedIDJAGIssuers string // Comma-separated per-client trusted-IdP issuer allowlist (admin-managed)
Status string // "pending", "active", "inactive"
TokenProfile string // "short", "standard", or "long"
Project string // Optional; emitted as JWT "project" claim
@@ -439,6 +441,7 @@ type ClientFormFieldsProps struct {
ScopePresetsOnly bool // Restrict scopes to preset chips only (user form)
ShowAllowedResources bool // Render the RFC 8707 AllowedResources tag picker (admin form only)
ShowRedirectURIPatterns bool // Render the origin-locked redirect URI pattern tag picker (admin form only)
+ ShowIDJAG bool // Render the Enterprise-Managed Authorization (ID-JAG) toggle + issuer allowlist (admin form only)
}
// UsersPageProps contains properties for the admin users list page
diff --git a/internal/token/external_verify.go b/internal/token/external_verify.go
new file mode 100644
index 00000000..2a05a2cd
--- /dev/null
+++ b/internal/token/external_verify.go
@@ -0,0 +1,83 @@
+package token
+
+import (
+ "crypto"
+ "crypto/ecdsa"
+ "crypto/rsa"
+ "errors"
+ "fmt"
+
+ "github.com/golang-jwt/jwt/v5"
+)
+
+var (
+ // ErrExternalUnsupportedAlg is returned when an external JWT is signed with
+ // an algorithm AuthGate will not accept. Only the asymmetric RS256/ES256 are
+ // validatable — AuthGate shares no symmetric secret with a third-party IdP,
+ // so HS256 (and "none") are rejected outright.
+ ErrExternalUnsupportedAlg = errors.New("external token uses an unsupported signing algorithm")
+ // ErrExternalInvalid is returned when an external JWT is malformed, expired,
+ // or its signature does not verify against the resolved key.
+ ErrExternalInvalid = errors.New("external token is invalid")
+)
+
+// externalValidMethods is the closed set of signing algorithms accepted for
+// externally-issued JWTs (ID-JAG assertions). Asymmetric only.
+var externalValidMethods = []string{"RS256", "ES256"}
+
+// KeyResolver resolves the public verification key for an external JWT from its
+// header parameters. Implementations fetch from a remote JWKS by kid. The
+// returned key must be an *rsa.PublicKey or *ecdsa.PublicKey.
+type KeyResolver func(kid, alg string) (crypto.PublicKey, error)
+
+// ParseExternalIssuer extracts the `iss` claim from an UNVERIFIED token. The
+// caller uses it to select the trusted IdP (and its JWKS endpoint) before the
+// signature is checked — the value MUST NOT be trusted until VerifyExternalJWT
+// succeeds against that IdP's keys.
+func ParseExternalIssuer(tokenString string) (string, error) {
+ claims := jwt.MapClaims{}
+ if _, _, err := jwt.NewParser().ParseUnverified(tokenString, claims); err != nil {
+ return "", fmt.Errorf("%w: %v", ErrExternalInvalid, err)
+ }
+ iss, _ := claims["iss"].(string)
+ return iss, nil
+}
+
+// VerifyExternalJWT verifies tokenString's signature with a key resolved via
+// resolve, restricted to RS256/ES256. The jwt library validates exp/nbf/iat.
+// Returns the verified claims, or ErrExternalInvalid / ErrExternalUnsupportedAlg
+// on failure. This path is independent of AuthGate's own signing key.
+func VerifyExternalJWT(tokenString string, resolve KeyResolver) (jwt.MapClaims, error) {
+ claims := jwt.MapClaims{}
+ keyFunc := func(t *jwt.Token) (any, error) {
+ alg, _ := t.Header["alg"].(string)
+ kid, _ := t.Header["kid"].(string)
+ key, err := resolve(kid, alg)
+ if err != nil {
+ return nil, err
+ }
+ // Defense-in-depth: the resolved key type must match the header alg.
+ switch alg {
+ case "RS256":
+ if _, ok := key.(*rsa.PublicKey); !ok {
+ return nil, fmt.Errorf("%w: %q key type mismatch", ErrExternalUnsupportedAlg, alg)
+ }
+ case "ES256":
+ if _, ok := key.(*ecdsa.PublicKey); !ok {
+ return nil, fmt.Errorf("%w: %q key type mismatch", ErrExternalUnsupportedAlg, alg)
+ }
+ default:
+ return nil, fmt.Errorf("%w: %q", ErrExternalUnsupportedAlg, alg)
+ }
+ return key, nil
+ }
+
+ parser := jwt.NewParser(jwt.WithValidMethods(externalValidMethods))
+ if _, err := parser.ParseWithClaims(tokenString, claims, keyFunc); err != nil {
+ if errors.Is(err, ErrExternalUnsupportedAlg) {
+ return nil, err
+ }
+ return nil, fmt.Errorf("%w: %v", ErrExternalInvalid, err)
+ }
+ return claims, nil
+}
diff --git a/internal/token/idjag.go b/internal/token/idjag.go
new file mode 100644
index 00000000..f7cbf7af
--- /dev/null
+++ b/internal/token/idjag.go
@@ -0,0 +1,63 @@
+package token
+
+import (
+ "time"
+
+ "github.com/golang-jwt/jwt/v5"
+ "github.com/google/uuid"
+)
+
+// Token type URNs used by the RFC 8693 token-exchange flow that mints ID-JAGs.
+const (
+ // TokenTypeIDJAG identifies an Identity Assertion JWT Authorization Grant
+ // (the MCP Enterprise-Managed Authorization extension).
+ TokenTypeIDJAG = "urn:ietf:params:oauth:token-type:id-jag"
+ // TokenTypeIDToken identifies an OIDC ID Token (the accepted subject_token
+ // type for ID-JAG issuance).
+ TokenTypeIDToken = "urn:ietf:params:oauth:token-type:id_token"
+)
+
+// IDJAGClaims carries the inputs for minting an ID-JAG. AuthGate (acting as IdP)
+// assembles these from a validated subject ID Token plus the token-exchange
+// request parameters; the values are server-attested at signing time.
+type IDJAGClaims struct {
+ Issuer string // iss — AuthGate's issuer identifier
+ Subject string // sub — the end-user the assertion vouches for
+ Audience string // aud — the downstream RAS the assertion is targeted at
+ Resource string // resource — the MCP/resource server the RAS should bind the access token to
+ ClientID string // client_id — the MCP client that will redeem the assertion
+ Scope string // scope — optional; omitted when empty
+ Expiry time.Duration
+}
+
+// GenerateIDJAG signs an ID-JAG with AuthGate's own signing key (the same key
+// exposed via /.well-known/jwks.json), so a downstream RAS can verify it through
+// AuthGate's JWKS. Returns the compact JWT and its absolute expiry.
+func (p *LocalTokenProvider) GenerateIDJAG(c IDJAGClaims) (string, time.Time, error) {
+ now := time.Now()
+ expiry := c.Expiry
+ if expiry <= 0 {
+ expiry = 5 * time.Minute
+ }
+ expiresAt := now.Add(expiry)
+
+ claims := jwt.MapClaims{
+ "iss": c.Issuer,
+ "sub": c.Subject,
+ "aud": c.Audience,
+ "jti": uuid.New().String(),
+ "iat": now.Unix(),
+ "exp": expiresAt.Unix(),
+ "resource": c.Resource,
+ "client_id": c.ClientID,
+ }
+ if c.Scope != "" {
+ claims["scope"] = c.Scope
+ }
+
+ signed, err := p.signClaims(claims)
+ if err != nil {
+ return "", time.Time{}, err
+ }
+ return signed, expiresAt, nil
+}