Skip to content

feat(oauth2): implement Identity Assertion JWT (ID-JAG) issuance (DEP #4600)#4611

Open
kanywst wants to merge 10 commits intodexidp:masterfrom
kanywst:feature/id-jag-phase1
Open

feat(oauth2): implement Identity Assertion JWT (ID-JAG) issuance (DEP #4600)#4611
kanywst wants to merge 10 commits intodexidp:masterfrom
kanywst:feature/id-jag-phase1

Conversation

@kanywst
Copy link
Copy Markdown
Contributor

@kanywst kanywst commented Mar 5, 2026

Overview

Implement ID-JAG (Identity Assertion JWT) token issuance via Token Exchange, per DEP #4600.

What this PR does / why we need it

Adds a new branch to the existing Token Exchange flow that issues ID-JAG tokens (typ: oauth-id-jag+jwt) when
requested_token_type=urn:ietf:params:oauth:token-type:id-jag. This enables cross-domain access brokered by the IdP,
as specified in draft-ietf-oauth-identity-assertion-authz-grant-02.

Key points:

  • Disabled by default; enabled via oauth2.tokenExchange.tokenTypes
  • Per-client idJAGPolicies in staticClients control allowed audiences/scopes (default-deny)
  • Validates subject_token audience against client_id, rejects public clients
  • OIDC Discovery extended with identity_chaining_requested_token_types_supported
  • Prometheus counters and structured logging for observability

Phase 2 (accepting ID-JAG as upstream identity via RFC 7523) is out of scope.

Connected DEP: #4600

Special notes for your reviewer

Config example in config.yaml.dist and examples/config-dev.yaml.

@kanywst kanywst force-pushed the feature/id-jag-phase1 branch from 135a54a to bdf0a6e Compare March 5, 2026 16:33
@kanywst kanywst changed the title feat(oauth2): implement Identity Assertion JWT (ID-JAG) issuance (DEP #4600 phase 1) feat(oauth2): implement Identity Assertion JWT (ID-JAG) issuance (DEP #4600) Mar 11, 2026
@kanywst kanywst marked this pull request as draft March 11, 2026 15:26
@kanywst kanywst force-pushed the feature/id-jag-phase1 branch from 87e1745 to f1de572 Compare March 14, 2026 10:04
@kanywst kanywst marked this pull request as ready for review March 14, 2026 10:05
@kanywst kanywst force-pushed the feature/id-jag-phase1 branch 4 times, most recently from 5b1e4e1 to 63b015e Compare March 18, 2026 17:41
@kanywst kanywst force-pushed the feature/id-jag-phase1 branch from 63b015e to 5bb1ebf Compare March 22, 2026 18:36
@kanywst
Copy link
Copy Markdown
Contributor Author

kanywst commented Mar 22, 2026

Hi @nabokihms @AlwxSin,

DEP #4600 has been merged, and this is the implementation PR. Everything described in the DEP is covered here.

Would appreciate a review when you get a chance.

Thanks!

Comment thread server/handlers.go Outdated

// extractJWTSubAndAud extracts the "sub" and "aud" claims from a JWT without
// verifying the signature. The aud claim may be a string or []string.
func extractJWTSubAndAud(token string) (sub string, aud []string, err error) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm concerned that extractJWTSubAndAud doesn't verify signature.

  • subject_token is an ID token issued by this same server and should be verified against the server's own signing keys. Otherwise an attacker can impersonate any user
  • Also, this func doesn't check whether the subject token has expired. An expierd ID token can be exchanged for a fresh ID-JAG indefinitely

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks.

Fixed in cc4bd0f. This verifies signature, expiry, issuer, and audience.

Comment thread server/policy.go
}

if matched == nil {
return PolicyResult{
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function returns both a non-nill error and sets Denied: true, which is confusing. I'm not sure which returned variable I should look at.
In Go convention, errors should signal unexpected failures, not business-logic denials.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c07a63a. Denials now return Denied: true with nil error

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now no code path returns a non-nil error. Can we we change signature to func evaluateIDJAGPolicy(policies []TokenExchangePolicy, clientID, audience string, scopes []string) PolicyResult {} without an error?

Comment thread server/signer/vault.go
return fmt.Sprintf("%s.%s.%s", headerB64, payloadB64, signatureB64), nil
}

func (v *vaultSigner) SignWithType(ctx context.Context, payload []byte, tokenType string) (string, error) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thi func is almost identical to Sign func.
Consider to separate common func logic

func Sign() {
  return sign(false)
}

func SignWithType() {
  return sign(true)
}

func sign(withType bool) {}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Fixed in 9fd8d05.

Comment thread server/server.go Outdated

c.PrometheusRegistry.MustRegister(requestCounter, durationHist, sizeHist)

// ID-JAG metrics.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding a check if ID-JAG is enabled. Otherwise there are phantom metrics that never increments, adding noise to monitoring dashboards

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4c6664d.

Comment thread server/handlers.go Outdated
userIdentity = &ui
}

// Skip approval if user already consented to the requested scopes for this client.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This check seems unrelated to token exchange. It modifies core auth flow

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed. Fixed in cc4bd0f.

Comment thread server/oauth2.go
type idJAGClaims struct {
Issuer string `json:"iss"`
Subject string `json:"sub"`
Audience string `json:"aud"`
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don;t know how to follow the RFC in a best way.
RFC allows aud to be either a string or an array. Current draft assumes only a string. So, if the ID-JAG draft allows multi-audience in the future, this will need a breaking change.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a comment noting that audience is a single string per the current draft (draft-ietf-oauth-identity-assertion-authz-grant-02).

If multi-audience is added in a future revision, we can update then. Fixed in 9ce3053.

@kanywst kanywst force-pushed the feature/id-jag-phase1 branch from b7f4587 to 9ce3053 Compare March 24, 2026 13:28
Comment thread server/policy.go
}

if matched == nil {
return PolicyResult{
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now no code path returns a non-nil error. Can we we change signature to func evaluateIDJAGPolicy(policies []TokenExchangePolicy, clientID, audience string, scopes []string) PolicyResult {} without an error?

Comment thread server/server.go Outdated
// IDJAGEnabled reports whether the ID-JAG token type is enabled.
func (c TokenExchangeConfig) IDJAGEnabled() bool {
for _, t := range c.TokenTypes {
if t == "urn:ietf:params:oauth:token-type:id-jag" {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a const tokenTypeIDJAG in oauth2.go

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 98ed352

Comment thread server/handlers.go
return
}

if _, err := s.getConnector(ctx, connectorID); err != nil {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please, add a comment explaining why the result from s.getConnector is unused. I figured it out only after checking log message

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. Added a comment in f397972

kanywst added 9 commits March 30, 2026 19:45
…exidp#4600)

Add support for Identity Assertion JWT Authorization Grant
(draft-ietf-oauth-identity-assertion-authz-grant-02) as a new
requested_token_type in the RFC 8693 Token Exchange flow.

- ID-JAG JWT with typ=oauth-id-jag+jwt, required claims (iss, sub,
  aud, client_id, jti, exp, iat) and optional claims (resource, scope)
- Per-client policy with default-deny: allowedAudiences, allowedScopes
- Scope filtering per Section 4.3.2 (granted scopes may differ from
  requested; response includes scope when modified)
- subject_token audience validation against client_id (Section 4.3)
- Public client rejection (Section 8.1)
- OIDC Discovery: identity_chaining_requested_token_types_supported
- SignWithType on signer interface for custom JWT typ header
- Prometheus counters: dex_id_jag_requests_total,
  dex_id_jag_policy_rejections_total, dex_id_jag_scope_modifications_total
- Structured logging on issuance and rejection with decision context

Signed-off-by: Takuma Niwa <takuma@because-and.com>
Signed-off-by: kanywst <niwatakuma@icloud.com>
…ange

Signed-off-by: kanywst <niwatakuma@icloud.com>
…olicy

Signed-off-by: kanywst <niwatakuma@icloud.com>
Signed-off-by: kanywst <niwatakuma@icloud.com>
Signed-off-by: kanywst <niwatakuma@icloud.com>
Signed-off-by: kanywst <niwatakuma@icloud.com>
No code path returns a non-nil error after the earlier refactor that
moved policy denials into PolicyResult.

Signed-off-by: kanywst <niwatakuma@icloud.com>
Signed-off-by: kanywst <niwatakuma@icloud.com>
…ange

Signed-off-by: kanywst <niwatakuma@icloud.com>
@kanywst kanywst force-pushed the feature/id-jag-phase1 branch from 9ce3053 to f397972 Compare March 30, 2026 10:52
Copy link
Copy Markdown
Contributor

@AlwxSin AlwxSin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great job. Thanks.

@nabokihms nabokihms added the release-note/new-feature Release note: Exciting New Features label Mar 30, 2026
…id-jag-phase1

# Conflicts:
#	config.yaml.dist
#	server/handlers.go
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release-note/new-feature Release note: Exciting New Features

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants