Skip to content

Commit 04bcafc

Browse files
committed
feat: add .well-known/jwt-vc-issuer endpoint and fix RFC 8615 URL construction
Per draft-ietf-oauth-sd-jwt-vc-15 §5.3: - Add /.well-known/jwt-vc-issuer endpoint to APIGW returning issuer metadata with jwks_uri pointing to /jwks - Fix JWKSKeyResolver to construct jwt-vc-issuer well-known URL per RFC 8615 §3 (insert between host and path components, not append) - Add buildWellKnownURL() utility for correct RFC 8615 construction: https://example.com/tenanthttps://example.com/.well-known/jwt-vc-issuer/tenant
1 parent 41a9da2 commit 04bcafc

File tree

6 files changed

+100
-10
lines changed

6 files changed

+100
-10
lines changed

internal/apigw/apiv1/handlers_oauth.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,24 @@ func (c *Client) JWKS(ctx context.Context) (*JWKSResponse, error) {
288288
return &JWKSResponse{Keys: keys}, nil
289289
}
290290

291+
// JWTVCIssuerMetadataResponse represents JWT VC Issuer Metadata per SD-JWT VC §5.3.
292+
type JWTVCIssuerMetadataResponse struct {
293+
Issuer string `json:"issuer"`
294+
JWKSURI string `json:"jwks_uri"`
295+
}
296+
297+
// JWTVCIssuerMetadata returns the JWT VC Issuer Metadata per draft-ietf-oauth-sd-jwt-vc §5.3.
298+
// This metadata is served at /.well-known/jwt-vc-issuer and allows verifiers to discover
299+
// the issuer's JWKS endpoint.
300+
func (c *Client) JWTVCIssuerMetadata(ctx context.Context) (*JWTVCIssuerMetadataResponse, error) {
301+
c.log.Debug("jwt-vc-issuer metadata request")
302+
303+
return &JWTVCIssuerMetadataResponse{
304+
Issuer: c.cfg.APIGW.PublicURL,
305+
JWKSURI: c.cfg.APIGW.PublicURL + "/jwks",
306+
}, nil
307+
}
308+
291309
type OauthAuthorizationConsentRequest struct {
292310
//AuthMethod string `json:"-"`
293311
SessionID string `json:"-"`

internal/apigw/httpserver/api.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ type Apiv1 interface {
4949
OAuthToken(ctx context.Context, req *openid4vci.TokenRequest) (*openid4vci.TokenResponse, error)
5050
OAuthMetadata(ctx context.Context) (*oauth2.AuthorizationServerMetadata, error)
5151
JWKS(ctx context.Context) (*apiv1.JWKSResponse, error)
52+
JWTVCIssuerMetadata(ctx context.Context) (*apiv1.JWTVCIssuerMetadataResponse, error)
5253

5354
//Revoke(ctx context.Context, req *apiv1.RevokeRequest) (*apiv1.RevokeReply, error)
5455

internal/apigw/httpserver/endpoints_oauth.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,20 @@ func (s *Service) endpointJWKS(ctx context.Context, c *gin.Context) (any, error)
139139
return reply, nil
140140
}
141141

142+
func (s *Service) endpointJWTVCIssuerMetadata(ctx context.Context, c *gin.Context) (any, error) {
143+
ctx, span := s.tracer.Start(ctx, "httpserver:endpointJWTVCIssuerMetadata")
144+
defer span.End()
145+
146+
reply, err := s.apiv1.JWTVCIssuerMetadata(ctx)
147+
if err != nil {
148+
span.SetStatus(codes.Error, err.Error())
149+
return nil, err
150+
}
151+
152+
c.SetAccepted("application/json")
153+
return reply, nil
154+
}
155+
142156
func (s *Service) endpointOAuthAuthorizationConsent(ctx context.Context, c *gin.Context) (any, error) {
143157
s.log.Debug("endpointOAuthAuthorizationConsent", "c.Request.URL", c.Request.URL.String(), "headers", c.Request.Header)
144158
_, span := s.tracer.Start(ctx, "httpserver:endpointAuthorizationConsent")

internal/apigw/httpserver/service.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ type Service struct {
3939
sessionsEncKey string
4040
sessionsAuthKey string
4141
sessionsName string
42-
samlSPService SAMLSPService
42+
samlSPService SAMLSPService
4343
oidcrpService OIDCRPService
4444
}
4545

@@ -54,10 +54,10 @@ func New(ctx context.Context, cfg *model.Cfg, apiv1 *apiv1.Client, tracer *trace
5454
server: &http.Server{
5555
ReadHeaderTimeout: 3 * time.Second,
5656
},
57-
eventPublisher: eventPublisher,
58-
samlSPService: samlSPService,
59-
oidcrpService: oidcrpService,
60-
sessionsName: "oauth_user_session",
57+
eventPublisher: eventPublisher,
58+
samlSPService: samlSPService,
59+
oidcrpService: oidcrpService,
60+
sessionsName: "oauth_user_session",
6161
sessionsOptions: sessions.Options{
6262
Path: "/",
6363
Domain: "",
@@ -149,6 +149,7 @@ func New(ctx context.Context, cfg *model.Cfg, apiv1 *apiv1.Client, tracer *trace
149149
s.httpHelpers.Server.RegEndpoint(ctx, rgRoot, http.MethodGet, ".well-known/openid-credential-issuer", http.StatusOK, s.endpointVCIMetadata)
150150

151151
s.httpHelpers.Server.RegEndpoint(ctx, rgRoot, http.MethodGet, ".well-known/oauth-authorization-server", http.StatusOK, s.endpointOAuthMetadata)
152+
s.httpHelpers.Server.RegEndpoint(ctx, rgRoot, http.MethodGet, ".well-known/jwt-vc-issuer", http.StatusOK, s.endpointJWTVCIssuerMetadata)
152153
s.httpHelpers.Server.RegEndpoint(ctx, rgRoot, http.MethodGet, "jwks", http.StatusOK, s.endpointJWKS)
153154
rgOAuthSession := rgRoot.Group("")
154155
rgOAuthSession.Use(s.httpHelpers.Middleware.UserSession(s.sessionsName, s.sessionsAuthKey, s.sessionsEncKey, s.sessionsOptions))

pkg/trust/jwks_resolver.go

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"encoding/json"
99
"fmt"
1010
"net/http"
11+
"net/url"
1112
"strings"
1213
"time"
1314

@@ -52,9 +53,9 @@ type JWKSResolverConfig struct {
5253
//
5354
// Resolved JWKS are cached per issuer URL. Keys are matched by kid.
5455
type JWKSKeyResolver struct {
55-
httpClient *http.Client
56-
cache *ttlcache.Cache[string, *cachedJWKS]
57-
parseJWK func(jwkData any) (crypto.PublicKey, error)
56+
httpClient *http.Client
57+
cache *ttlcache.Cache[string, *cachedJWKS]
58+
parseJWK func(jwkData any) (crypto.PublicKey, error)
5859
}
5960

6061
// cachedJWKS holds the parsed JWKS keys for an issuer.
@@ -207,9 +208,12 @@ func (r *JWKSKeyResolver) fetchIssuerJWKS(ctx context.Context, issuerURL string)
207208
}
208209

209210
// tryJWTVCIssuerMetadata tries the SD-JWT VC §5.3 primary discovery endpoint.
211+
// The well-known URL is constructed per RFC 8615 §3: the well-known string is inserted
212+
// between the host component and the path component of the issuer URL.
210213
func (r *JWKSKeyResolver) tryJWTVCIssuerMetadata(ctx context.Context, baseURL, issuerURL string) ([]json.RawMessage, error) {
214+
metadataURL := buildWellKnownURL(baseURL, "jwt-vc-issuer")
211215
var metadata jwtVCIssuerMetadata
212-
if _, err := r.fetchJSON(ctx, baseURL+"/.well-known/jwt-vc-issuer", &metadata); err != nil {
216+
if _, err := r.fetchJSON(ctx, metadataURL, &metadata); err != nil {
213217
return nil, err
214218
}
215219

@@ -348,6 +352,30 @@ func (r *JWKSKeyResolver) fetchJSON(ctx context.Context, url string, target any)
348352
return target, nil
349353
}
350354

355+
// buildWellKnownURL constructs a well-known URL per RFC 8615 §3.
356+
// The well-known suffix is inserted between the host and path components of the entity URL.
357+
// For example, with suffix "jwt-vc-issuer":
358+
//
359+
// https://example.com → https://example.com/.well-known/jwt-vc-issuer
360+
// https://example.com/tenant/1 → https://example.com/.well-known/jwt-vc-issuer/tenant/1
361+
func buildWellKnownURL(entity, suffix string) string {
362+
entity = strings.TrimSuffix(entity, "/")
363+
364+
parsed, err := url.Parse(entity)
365+
if err != nil || parsed.Host == "" {
366+
// Best-effort fallback: just append
367+
return entity + "/.well-known/" + suffix
368+
}
369+
370+
path := strings.TrimPrefix(parsed.Path, "/")
371+
base := parsed.Scheme + "://" + parsed.Host
372+
373+
if path == "" {
374+
return base + "/.well-known/" + suffix
375+
}
376+
return base + "/.well-known/" + suffix + "/" + path
377+
}
378+
351379
// Stop stops the cache's automatic expiration goroutine.
352380
func (r *JWKSKeyResolver) Stop() {
353381
r.cache.Stop()

pkg/trust/jwks_resolver_test.go

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -376,7 +376,7 @@ func TestJWKSKeyResolverFallbackCredentialIssuerWithExplicitAS(t *testing.T) {
376376
switch r.URL.Path {
377377
case "/.well-known/openid-credential-issuer":
378378
meta := map[string]any{
379-
"credential_issuer": serverURL,
379+
"credential_issuer": serverURL,
380380
"authorization_servers": []string{serverURL + "/auth"},
381381
}
382382
w.Header().Set("Content-Type", "application/json")
@@ -452,3 +452,31 @@ func TestJWKSKeyResolverInvalidateIssuer(t *testing.T) {
452452
require.NoError(t, err)
453453
assert.Equal(t, 2, callCount) // now 2, fetched again
454454
}
455+
456+
func TestBuildWellKnownURL(t *testing.T) {
457+
tests := []struct {
458+
entity string
459+
suffix string
460+
want string
461+
}{
462+
// Host-only
463+
{"https://example.com", "jwt-vc-issuer", "https://example.com/.well-known/jwt-vc-issuer"},
464+
// Trailing slash
465+
{"https://example.com/", "jwt-vc-issuer", "https://example.com/.well-known/jwt-vc-issuer"},
466+
// Path-based (RFC 8615 §3: insert between host and path)
467+
{"https://example.com/tenant1", "jwt-vc-issuer", "https://example.com/.well-known/jwt-vc-issuer/tenant1"},
468+
// Deep path
469+
{"https://example.com/org/tenant/v1", "jwt-vc-issuer", "https://example.com/.well-known/jwt-vc-issuer/org/tenant/v1"},
470+
// With port
471+
{"https://example.com:8443/tenant", "jwt-vc-issuer", "https://example.com:8443/.well-known/jwt-vc-issuer/tenant"},
472+
// HTTP (test servers)
473+
{"http://127.0.0.1:12345", "jwt-vc-issuer", "http://127.0.0.1:12345/.well-known/jwt-vc-issuer"},
474+
}
475+
476+
for _, tt := range tests {
477+
t.Run(tt.entity, func(t *testing.T) {
478+
got := buildWellKnownURL(tt.entity, tt.suffix)
479+
assert.Equal(t, tt.want, got)
480+
})
481+
}
482+
}

0 commit comments

Comments
 (0)