From cb1fbb208a01eb89d2d6e7a0d0074a068900a712 Mon Sep 17 00:00:00 2001 From: mqf20 Date: Wed, 23 Jul 2025 00:59:22 +0800 Subject: [PATCH 01/44] WIP add support for rfc7591, 7592 Signed-off-by: mqf20 --- pkg/oidc/discovery.go | 2 +- pkg/oidc/dynamic_client_registration.go | 41 ++++++ pkg/op/config.go | 1 + pkg/op/discovery.go | 2 + pkg/op/dynamic_client_registration.go | 161 ++++++++++++++++++++++++ pkg/op/op.go | 18 +++ 6 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 pkg/oidc/dynamic_client_registration.go create mode 100644 pkg/op/dynamic_client_registration.go diff --git a/pkg/oidc/discovery.go b/pkg/oidc/discovery.go index 62288d1b..c6fd3d14 100644 --- a/pkg/oidc/discovery.go +++ b/pkg/oidc/discovery.go @@ -35,7 +35,7 @@ type DiscoveryConfiguration struct { // It may also contain the OP's encryption keys that RPs can use to encrypt request to the OP. JwksURI string `json:"jwks_uri,omitempty"` - // RegistrationEndpoint is the URL for the Dynamic Client Registration. + // RegistrationEndpoint is the URL for the Dynamic Client Registration (RFC7591, RFC7592).. RegistrationEndpoint string `json:"registration_endpoint,omitempty"` // ScopesSupported lists an array of supported scopes. This list must not include every supported scope by the OP. diff --git a/pkg/oidc/dynamic_client_registration.go b/pkg/oidc/dynamic_client_registration.go new file mode 100644 index 00000000..0f113f56 --- /dev/null +++ b/pkg/oidc/dynamic_client_registration.go @@ -0,0 +1,41 @@ +package oidc + +// ClientRegistrationRequest implements +// https://www.rfc-editor.org/rfc/rfc7591#section-3.1, +// 3.1 Client Registration Request. +type ClientRegistrationRequest struct { + // TODO +} + +// ClientInformationResponse implements +// https://www.rfc-editor.org/rfc/rfc7591#section-3.2.1, +// 3.2.1. Client Information Response and +// https://www.rfc-editor.org/rfc/rfc7592.html#section-3 +// 3. Client Information Response. +type ClientInformationResponse struct { + // TODO +} + +// ClientReadRequest implements +// https://www.rfc-editor.org/rfc/rfc7592.html#section-2.1, +// 2.1 Client Read Request. +type ClientReadRequest struct { + // TODO + ClientID string `schema:"client_id"` +} + +// ClientUpdateRequest implements +// https://www.rfc-editor.org/rfc/rfc7592.html#section-2.2, +// 2.2 Client Update Request. +type ClientUpdateRequest struct { + // TODO + ClientID string `schema:"client_id"` +} + +// ClientDeleteRequest implements +// https://www.rfc-editor.org/rfc/rfc7592.html#section-2.3, +// 2.3 Client Delete Request. +type ClientDeleteRequest struct { + // TODO + ClientID string `schema:"client_id"` +} diff --git a/pkg/op/config.go b/pkg/op/config.go index b2717654..8fb64c07 100644 --- a/pkg/op/config.go +++ b/pkg/op/config.go @@ -31,6 +31,7 @@ type Configuration interface { KeysEndpoint() *Endpoint DeviceAuthorizationEndpoint() *Endpoint CheckSessionIframe() *Endpoint + RegistrationEndpoint() *Endpoint AuthMethodPostSupported() bool CodeMethodS256Supported() bool diff --git a/pkg/op/discovery.go b/pkg/op/discovery.go index 7aa7cf72..8a8a8aa9 100644 --- a/pkg/op/discovery.go +++ b/pkg/op/discovery.go @@ -46,6 +46,7 @@ func CreateDiscoveryConfig(ctx context.Context, config Configuration, storage Di JwksURI: config.KeysEndpoint().Absolute(issuer), DeviceAuthorizationEndpoint: config.DeviceAuthorizationEndpoint().Absolute(issuer), CheckSessionIframe: config.CheckSessionIframe().Absolute(issuer), + RegistrationEndpoint: config.RegistrationEndpoint().Absolute(issuer), ScopesSupported: Scopes(config), ResponseTypesSupported: ResponseTypes(config), GrantTypesSupported: GrantTypes(config), @@ -79,6 +80,7 @@ func createDiscoveryConfigV2(ctx context.Context, config Configuration, storage EndSessionEndpoint: endpoints.EndSession.Absolute(issuer), JwksURI: endpoints.JwksURI.Absolute(issuer), DeviceAuthorizationEndpoint: endpoints.DeviceAuthorization.Absolute(issuer), + RegistrationEndpoint: endpoints.Registration.Absolute(issuer), ScopesSupported: Scopes(config), ResponseTypesSupported: ResponseTypes(config), GrantTypesSupported: GrantTypes(config), diff --git a/pkg/op/dynamic_client_registration.go b/pkg/op/dynamic_client_registration.go new file mode 100644 index 00000000..e840e373 --- /dev/null +++ b/pkg/op/dynamic_client_registration.go @@ -0,0 +1,161 @@ +package op + +import ( + "fmt" + httphelper "github.com/zitadel/oidc/v3/pkg/http" + "github.com/zitadel/oidc/v3/pkg/oidc" + "net/http" +) + +func RegistrationHandler(o OpenIDProvider) func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + if err := ClientRegistration(w, r, o); err != nil { + RequestError(w, r, err, o.Logger()) + } + case http.MethodGet: + if err := ClientRead(w, r, o); err != nil { + RequestError(w, r, err, o.Logger()) + } + case http.MethodPut: + if err := ClientUpdate(w, r, o); err != nil { + RequestError(w, r, err, o.Logger()) + } + case http.MethodDelete: + if err := ClientDelete(w, r, o); err != nil { + RequestError(w, r, err, o.Logger()) + } + default: + RequestError(w, r, fmt.Errorf("unsupported method: %s", r.Method), o.Logger()) + } + } +} + +// ClientRegistration handles [client registration requests] as part of the +// [OAuth 2.0 Dynamic Client Registration Protocol]. +// +// [client registration requests]: https://www.rfc-editor.org/rfc/rfc7591#section-3.1 +// [OAuth 2.0 Dynamic Client Registration Protocol]: https://www.rfc-editor.org/rfc/rfc7591 +func ClientRegistration(w http.ResponseWriter, r *http.Request, o OpenIDProvider) error { + ctx, span := tracer.Start(r.Context(), "ClientRegistration") + r = r.WithContext(ctx) + defer span.End() + + req, err := ParseClientRegistrationRequest(r, o) + if err != nil { + return err + } + _ = req + httphelper.MarshalJSON(w, oidc.ClientInformationResponse{}) + return nil +} + +func ParseClientRegistrationRequest(r *http.Request, o OpenIDProvider) (*oidc.ClientRegistrationRequest, error) { + ctx, span := tracer.Start(r.Context(), "ParseClientRegistrationRequest") + r = r.WithContext(ctx) + defer span.End() + + req := new(oidc.ClientRegistrationRequest) + if err := o.Decoder().Decode(req, r.Form); err != nil { + return nil, oidc.ErrInvalidRequest().WithDescription("cannot parse client registration request").WithParent(err) + } + + return req, nil +} + +// ClientRead handles [client read requests] as part of the +// [OAuth 2.0 Dynamic Client Registration Management Protocol]. +// +// [client read requests]: https://www.rfc-editor.org/rfc/rfc7592.html#section-2.1 +// [OAuth 2.0 Dynamic Client Registration Management Protocol]: https://www.rfc-editor.org/rfc/rfc7592.html +func ClientRead(w http.ResponseWriter, r *http.Request, o OpenIDProvider) error { + ctx, span := tracer.Start(r.Context(), "ClientRead") + r = r.WithContext(ctx) + defer span.End() + + req, err := ParseClientReadRequest(r, o) + if err != nil { + return err + } + _ = req + httphelper.MarshalJSON(w, oidc.ClientInformationResponse{}) + return nil +} + +func ParseClientReadRequest(r *http.Request, o OpenIDProvider) (*oidc.ClientReadRequest, error) { + ctx, span := tracer.Start(r.Context(), "ParseClientReadRequest") + r = r.WithContext(ctx) + defer span.End() + + req := new(oidc.ClientReadRequest) + if err := o.Decoder().Decode(req, r.Form); err != nil { + return nil, oidc.ErrInvalidRequest().WithDescription("cannot parse client read request").WithParent(err) + } + + return req, nil +} + +// ClientUpdate handles [client update requests] as part of the +// [OAuth 2.0 Dynamic Client Registration Management Protocol]. +// +// [client update requests]: https://www.rfc-editor.org/rfc/rfc7592.html#section-2.2 +// [OAuth 2.0 Dynamic Client Registration Management Protocol]: https://www.rfc-editor.org/rfc/rfc7592.html +func ClientUpdate(w http.ResponseWriter, r *http.Request, o OpenIDProvider) error { + ctx, span := tracer.Start(r.Context(), "ClientUpdate") + r = r.WithContext(ctx) + defer span.End() + + req, err := ParseClientUpdateRequest(r, o) + if err != nil { + return err + } + _ = req + httphelper.MarshalJSON(w, oidc.ClientInformationResponse{}) + return nil +} + +func ParseClientUpdateRequest(r *http.Request, o OpenIDProvider) (*oidc.ClientUpdateRequest, error) { + ctx, span := tracer.Start(r.Context(), "ParseClientUpdateRequest") + r = r.WithContext(ctx) + defer span.End() + + req := new(oidc.ClientUpdateRequest) + if err := o.Decoder().Decode(req, r.Form); err != nil { + return nil, oidc.ErrInvalidRequest().WithDescription("cannot parse client update request").WithParent(err) + } + + return req, nil +} + +// ClientDelete handles [client delete requests] as part of the +// [OAuth 2.0 Dynamic Client Registration Management Protocol]. +// +// [client delete requests]: https://www.rfc-editor.org/rfc/rfc7592.html#section-2.3 +// [OAuth 2.0 Dynamic Client Registration Management Protocol]: https://www.rfc-editor.org/rfc/rfc7592.html +func ClientDelete(w http.ResponseWriter, r *http.Request, o OpenIDProvider) error { + ctx, span := tracer.Start(r.Context(), "ClientDelete") + r = r.WithContext(ctx) + defer span.End() + + req, err := ParseClientDeleteRequest(r, o) + if err != nil { + return err + } + _ = req + httphelper.MarshalJSON(w, oidc.ClientInformationResponse{}) + return nil +} + +func ParseClientDeleteRequest(r *http.Request, o OpenIDProvider) (*oidc.ClientDeleteRequest, error) { + ctx, span := tracer.Start(r.Context(), "ParseClientDeleteRequest") + r = r.WithContext(ctx) + defer span.End() + + req := new(oidc.ClientDeleteRequest) + if err := o.Decoder().Decode(req, r.Form); err != nil { + return nil, oidc.ErrInvalidRequest().WithDescription("cannot parse client delete request").WithParent(err) + } + + return req, nil +} diff --git a/pkg/op/op.go b/pkg/op/op.go index b7611511..fa86d407 100644 --- a/pkg/op/op.go +++ b/pkg/op/op.go @@ -30,6 +30,7 @@ const ( defaultEndSessionEndpoint = "end_session" defaultKeysEndpoint = "keys" defaultDeviceAuthzEndpoint = "/device_authorization" + defaultRegistrationEndpoint = "oauth/register" ) var ( @@ -42,6 +43,7 @@ var ( EndSession: NewEndpoint(defaultEndSessionEndpoint), JwksURI: NewEndpoint(defaultKeysEndpoint), DeviceAuthorization: NewEndpoint(defaultDeviceAuthzEndpoint), + Registration: NewEndpoint(defaultRegistrationEndpoint), } DefaultSupportedClaims = []string{ @@ -143,6 +145,7 @@ func CreateRouter(o OpenIDProvider, interceptors ...HttpInterceptor) chi.Router router.HandleFunc(o.EndSessionEndpoint().Relative(), endSessionHandler(o)) router.HandleFunc(o.KeysEndpoint().Relative(), keysHandler(o.Storage())) router.HandleFunc(o.DeviceAuthorizationEndpoint().Relative(), DeviceAuthorizationHandler(o)) + router.HandleFunc(o.RegistrationEndpoint().Relative(), RegistrationHandler(o)) return router } @@ -184,6 +187,7 @@ type Endpoints struct { CheckSessionIframe *Endpoint JwksURI *Endpoint DeviceAuthorization *Endpoint + Registration *Endpoint } // NewOpenIDProvider creates a provider. The provider provides (with HttpHandler()) @@ -343,6 +347,10 @@ func (o *Provider) CheckSessionIframe() *Endpoint { return o.endpoints.CheckSessionIframe } +func (o *Provider) RegistrationEndpoint() *Endpoint { + return o.endpoints.Registration +} + func (o *Provider) KeysEndpoint() *Endpoint { return o.endpoints.JwksURI } @@ -587,6 +595,16 @@ func WithCustomDeviceAuthorizationEndpoint(endpoint *Endpoint) Option { } } +func WithCustomRegisterEndpoint(endpoint *Endpoint) Option { + return func(o *Provider) error { + if err := endpoint.Validate(); err != nil { + return err + } + o.endpoints.Registration = endpoint + return nil + } +} + // WithCustomEndpoints sets multiple endpoints at once. // Non of the endpoints may be nil, or an error will // be returned when the Option used by the Provider. From 03971db7386dadc76dc1632e557416ab1d6659e9 Mon Sep 17 00:00:00 2001 From: mqf20 Date: Wed, 23 Jul 2025 01:25:40 +0800 Subject: [PATCH 02/44] WIP request Signed-off-by: mqf20 --- pkg/oidc/dynamic_client_registration.go | 14 +++- pkg/oidc/dynamic_client_registration_test.go | 84 ++++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 pkg/oidc/dynamic_client_registration_test.go diff --git a/pkg/oidc/dynamic_client_registration.go b/pkg/oidc/dynamic_client_registration.go index 0f113f56..e09c0d19 100644 --- a/pkg/oidc/dynamic_client_registration.go +++ b/pkg/oidc/dynamic_client_registration.go @@ -4,7 +4,19 @@ package oidc // https://www.rfc-editor.org/rfc/rfc7591#section-3.1, // 3.1 Client Registration Request. type ClientRegistrationRequest struct { - // TODO + ApplicationType string `json:"application_type"` + RedirectUris []string `json:"redirect_uris"` + // ClientName contains a list of BCP47 language tag values that the OP supports. + ClientName Locales `json:"client_name"` + LogoUri Locales `json:"logo_uri"` + SubjectType string `json:"subject_type"` + SectorIdentifierUri Locales `json:"sector_identifier_uri"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` + JwksUri string `json:"jwks_uri"` + UserinfoEncryptedResponseAlg string `json:"userinfo_encrypted_response_alg"` + UserinfoEncryptedResponseEnc string `json:"userinfo_encrypted_response_enc"` + Contacts []string `json:"contacts"` + RequestUris []string `json:"request_uris"` } // ClientInformationResponse implements diff --git a/pkg/oidc/dynamic_client_registration_test.go b/pkg/oidc/dynamic_client_registration_test.go new file mode 100644 index 00000000..8bd6a8f2 --- /dev/null +++ b/pkg/oidc/dynamic_client_registration_test.go @@ -0,0 +1,84 @@ +package oidc + +import ( + "encoding/json" + "github.com/stretchr/testify/require" + "testing" +) + +func TestClientRegistrationRequest(t *testing.T) { + t.Run("unmarshal example from https://www.rfc-editor.org/rfc/rfc7591#page-17", func(t *testing.T) { + marshalled := []byte(` +{ + "redirect_uris": [ + "https://client.example.org/callback", + "https://client.example.org/callback2" + ], + "client_name": "My Example Client", + "client_name#ja-Jpan-JP": "\u30AF\u30E9\u30A4\u30A2\u30F3\u30C8\u540D", + "token_endpoint_auth_method": "client_secret_basic", + "logo_uri": "https://client.example.org/logo.png", + "jwks_uri": "https://client.example.org/my_public_keys.jwks", + "example_extension_parameter": "example_value" +} +`) + var req ClientRegistrationRequest + err := json.Unmarshal(marshalled, &req) + require.NoError(t, err) + }) + t.Run("unmarshal example from https://www.rfc-editor.org/rfc/rfc7591#page-18", func(t *testing.T) { + marshalled := []byte(` +{ + "redirect_uris": [ + "https://client.example.org/callback", + "https://client.example.org/callback2" + ], + "client_name": "My Example Client", + "client_name#ja-Jpan-JP": "\u30AF\u30E9\u30A4\u30A2\u30F3\u30C8\u540D", + "token_endpoint_auth_method": "client_secret_basic", + "policy_uri": "https://client.example.org/policy.html", + "jwks": { + "keys": [{ + "e": "AQAB", + "n": "nj3YJwsLUFl9BmpAbkOswCNVx17Eh9wMO-_AReZwBqfaWFcfG + HrZXsIV2VMCNVNU8Tpb4obUaSXcRcQ-VMsfQPJm9IzgtRdAY8NN8Xb7PEcYyk + lBjvTtuPbpzIaqyiUepzUXNDFuAOOkrIol3WmflPUUgMKULBN0EUd1fpOD70p + RM0rlp_gg_WNUKoW1V-3keYUJoXH9NztEDm_D2MQXj9eGOJJ8yPgGL8PAZMLe + 2R7jb9TxOCPDED7tY_TU4nFPlxptw59A42mldEmViXsKQt60s1SLboazxFKve + qXC_jpLUt22OC6GUG63p-REw-ZOr3r845z50wMuzifQrMI9bQ", + "kty": "RSA" + }] + }, + "example_extension_parameter": "example_value" +} +`) + var req ClientRegistrationRequest + err := json.Unmarshal(marshalled, &req) + require.NoError(t, err) + }) + t.Run("unmarshal example from https://www.rfc-editor.org/rfc/rfc7591#page-19", func(t *testing.T) { + marshalled := []byte(` +{ + "redirect_uris": [ + "https://client.example.org/callback", + "https://client.example.org/callback2" + ], + "software_statement": "eyJhbGciOiJSUzI1NiJ9. + eyJzb2Z0d2FyZV9pZCI6IjROUkIxLTBYWkFCWkk5RTYtNVNNM1IiLCJjbGll + bnRfbmFtZSI6IkV4YW1wbGUgU3RhdGVtZW50LWJhc2VkIENsaWVudCIsImNs + aWVudF91cmkiOiJodHRwczovL2NsaWVudC5leGFtcGxlLm5ldC8ifQ. + GHfL4QNIrQwL18BSRdE595T9jbzqa06R9BT8w409x9oIcKaZo_mt15riEXHa + zdISUvDIZhtiyNrSHQ8K4TvqWxH6uJgcmoodZdPwmWRIEYbQDLqPNxREtYn0 + 5X3AR7ia4FRjQ2ojZjk5fJqJdQ-JcfxyhK-P8BAWBd6I2LLA77IG32xtbhxY + fHX7VhuU5ProJO8uvu3Ayv4XRhLZJY4yKfmyjiiKiPNe-Ia4SMy_d_QSWxsk + U5XIQl5Sa2YRPMbDRXttm2TfnZM1xx70DoYi8g6czz-CPGRi4SW_S2RKHIJf + IjoI3zTJ0Y2oe0_EJAiXbL6OyF9S5tKxDXV8JIndSA", + "scope": "read write", + "example_extension_parameter": "example_value" +} +`) + var req ClientRegistrationRequest + err := json.Unmarshal(marshalled, &req) + require.NoError(t, err) + }) +} From 5fdcb5d7529ddcfe0ceb03c6539e029d3fd64780 Mon Sep 17 00:00:00 2001 From: mqf20 Date: Thu, 24 Jul 2025 21:52:07 +0800 Subject: [PATCH 03/44] WIP updated mock Signed-off-by: mqf20 --- pkg/op/mock/configuration.mock.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/pkg/op/mock/configuration.mock.go b/pkg/op/mock/configuration.mock.go index 0ef9d924..27b34d11 100644 --- a/pkg/op/mock/configuration.mock.go +++ b/pkg/op/mock/configuration.mock.go @@ -455,3 +455,17 @@ func (mr *MockConfigurationMockRecorder) UserinfoEndpoint() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UserinfoEndpoint", reflect.TypeOf((*MockConfiguration)(nil).UserinfoEndpoint)) } + +// RegistrationEndpoint mocks base method. +func (m *MockConfiguration) RegistrationEndpoint() *op.Endpoint { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RegistrationEndpoint") + ret0, _ := ret[0].(*op.Endpoint) + return ret0 +} + +// RegistrationEndpoint indicates an expected call of RegistrationEndpoint. +func (mr *MockConfigurationMockRecorder) RegistrationEndpoint() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RegistrationEndpoint", reflect.TypeOf((*MockConfiguration)(nil).RegistrationEndpoint)) +} From fc3b6cdcbd0d1231e1c08476f4a66371d4be027d Mon Sep 17 00:00:00 2001 From: mqf20 Date: Fri, 25 Jul 2025 20:37:11 +0800 Subject: [PATCH 04/44] WIP added request and response structs Signed-off-by: mqf20 --- pkg/oidc/dynamic_client_registration.go | 92 +++++++----- pkg/oidc/dynamic_client_registration_test.go | 139 ++++++++++++++++--- 2 files changed, 178 insertions(+), 53 deletions(-) diff --git a/pkg/oidc/dynamic_client_registration.go b/pkg/oidc/dynamic_client_registration.go index e09c0d19..7ed6733b 100644 --- a/pkg/oidc/dynamic_client_registration.go +++ b/pkg/oidc/dynamic_client_registration.go @@ -1,22 +1,31 @@ package oidc +import ( + "github.com/go-jose/go-jose/v4" +) + // ClientRegistrationRequest implements // https://www.rfc-editor.org/rfc/rfc7591#section-3.1, // 3.1 Client Registration Request. +// +// Can also be used for https://www.rfc-editor.org/rfc/rfc7592.html#section-2.1 +// 2.2 Client Update Request. type ClientRegistrationRequest struct { - ApplicationType string `json:"application_type"` - RedirectUris []string `json:"redirect_uris"` - // ClientName contains a list of BCP47 language tag values that the OP supports. - ClientName Locales `json:"client_name"` - LogoUri Locales `json:"logo_uri"` - SubjectType string `json:"subject_type"` - SectorIdentifierUri Locales `json:"sector_identifier_uri"` - TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` - JwksUri string `json:"jwks_uri"` - UserinfoEncryptedResponseAlg string `json:"userinfo_encrypted_response_alg"` - UserinfoEncryptedResponseEnc string `json:"userinfo_encrypted_response_enc"` - Contacts []string `json:"contacts"` - RequestUris []string `json:"request_uris"` + RedirectURIs []string `json:"redirect_uris"` // Array of redirection URI strings for use in redirect-based flows such as the authorization code and implicit flows. + TokenEndpointAuthMethod AuthMethod `json:"token_endpoint_auth_method"` // String indicator of the requested authentication method for the token endpoint. + GrantTypes []GrantType `json:"grant_types"` // Array of OAuth 2.0 grant type strings that the client can use at the token endpoint. + ResponseTypes []ResponseType `json:"response_types"` // Array of the OAuth 2.0 response type strings that the client can use at the authorization endpoint. + ClientName string `json:"client_name"` // Human-readable string name of the client to be presented to the end-user during authorization. (BCP 47) + ClientURI string `json:"client_uri"` // URL string of a web page providing information about the client. (BCP 47) + LogoURI string `json:"logo_uri"` // URL string that references a logo for the client. (BCP 47) + Scope string `json:"scope"` // String containing a space-separated list of scope values (as described in Section 3.3 of OAuth 2.0 [RFC6749]) that the client can use when requesting access tokens. + Contacts []string `json:"contacts"` // Array of strings representing ways to contact people responsible for this client, typically email addresses. + TOSURI string `json:"tos_uri"` // URL string that points to a human-readable terms of service document for the client that describes a contractual relationship between the end-user and the client that the end-user accepts when authorizing the client. (BCP 47) + PolicyURI string `json:"policy_uri"` // URL string that points to a human-readable privacy policy document that describes how the deployment organization collects, uses, retains, and discloses personal data. + JWKSURI string `json:"jwks_uri"` // URL string referencing the client's JSON Web Key (JWK) Set [RFC7517] document, which contains the client's public keys. + JWKS jose.JSONWebKeySet `json:"jwks"` // Client's JSON Web Key Set [RFC7517] document value, which contains the client's public keys. + SoftwareID string `json:"software_id"` // A unique identifier string (e.g., a Universally Unique Identifier (UUID)) assigned by the client developer or software publisher used by registration endpoints to identify the client software to be dynamically registered. + SoftwareVersion string `json:"software_version"` // A version identifier string for the client software identified by "software_id". } // ClientInformationResponse implements @@ -25,29 +34,44 @@ type ClientRegistrationRequest struct { // https://www.rfc-editor.org/rfc/rfc7592.html#section-3 // 3. Client Information Response. type ClientInformationResponse struct { - // TODO -} + ClientID string `json:"client_id"` // OAuth 2.0 client identifier string. + ClientSecret string `json:"client_secret,omitempty"` // OAuth 2.0 client secret string. + ClientIDIssuedAt int64 `json:"client_id_issued_at,omitempty"` // Time at which the client identifier was issued. + ClientSecretExpiresAt int64 `json:"client_secret_expires_at,omitempty"` // Time at which the client secret will expire or 0 if it will not expire. -// ClientReadRequest implements -// https://www.rfc-editor.org/rfc/rfc7592.html#section-2.1, -// 2.1 Client Read Request. -type ClientReadRequest struct { - // TODO - ClientID string `schema:"client_id"` + // fields that are reused from ClientRegistrationRequest + RedirectURIs []string `json:"redirect_uris,omitempty"` // Array of redirection URI strings for use in redirect-based flows such as the authorization code and implicit flows. + TokenEndpointAuthMethod AuthMethod `json:"token_endpoint_auth_method,omitempty"` // String indicator of the requested authentication method for the token endpoint. + GrantTypes []GrantType `json:"grant_types,omitempty"` // Array of OAuth 2.0 grant type strings that the client can use at the token endpoint. + ResponseTypes []ResponseType `json:"response_types,omitempty"` // Array of the OAuth 2.0 response type strings that the client can use at the authorization endpoint. + ClientName string `json:"client_name,omitempty"` // Human-readable string name of the client to be presented to the end-user during authorization. (BCP 47) + ClientURI string `json:"client_uri,omitempty"` // URL string of a web page providing information about the client. (BCP 47) + LogoURI string `json:"logo_uri,omitempty"` // URL string that references a logo for the client. (BCP 47) + Scope string `json:"scope,omitempty"` // String containing a space-separated list of scope values (as described in Section 3.3 of OAuth 2.0 [RFC6749]) that the client can use when requesting access tokens. + Contacts []string `json:"contacts,omitempty"` // Array of strings representing ways to contact people responsible for this client, typically email addresses. + TOSURI string `json:"tos_uri,omitempty"` // URL string that points to a human-readable terms of service document for the client that describes a contractual relationship between the end-user and the client that the end-user accepts when authorizing the client. (BCP 47) + PolicyURI string `json:"policy_uri,omitempty"` // URL string that points to a human-readable privacy policy document that describes how the deployment organization collects, uses, retains, and discloses personal data. (BCP 47) + JWKSURI string `json:"jwks_uri,omitempty"` // URL string referencing the client's JSON Web Key (JWK) Set [RFC7517] document, which contains the client's public keys. + JWKS jose.JSONWebKeySet `json:"jwks,omitempty"` // Client's JSON Web Key Set [RFC7517] document value, which contains the client's public keys. + SoftwareID string `json:"software_id,omitempty"` // A unique identifier string (e.g., a Universally Unique Identifier (UUID)) assigned by the client developer or software publisher used by registration endpoints to identify the client software to be dynamically registered. + SoftwareVersion string `json:"software_version,omitempty"` // A version identifier string for the client software identified by "software_id". } -// ClientUpdateRequest implements -// https://www.rfc-editor.org/rfc/rfc7592.html#section-2.2, -// 2.2 Client Update Request. -type ClientUpdateRequest struct { - // TODO - ClientID string `schema:"client_id"` +// ClientInformationErrorResponse implements +// https://www.rfc-editor.org/rfc/rfc7591#section-3.2.1, +// 3.2.1. Client Information Response and +// https://www.rfc-editor.org/rfc/rfc7592.html#section-3 +// 3. Client Information Response. +type ClientInformationErrorResponse struct { + Error ClientInformationErrorResponseErrorCode `json:"error"` // Single ASCII error code string. + ErrorDescription string `json:"error_description,omitempty"` // Human-readable ASCII text description of the error used for debugging. } -// ClientDeleteRequest implements -// https://www.rfc-editor.org/rfc/rfc7592.html#section-2.3, -// 2.3 Client Delete Request. -type ClientDeleteRequest struct { - // TODO - ClientID string `schema:"client_id"` -} +const ( + ClientInformationErrorResponseErrorCodeInvalidRedirectURI ClientInformationErrorResponseErrorCode = "invalid_redirect_uri" // The value of one or more redirection URIs is invalid. + ClientInformationErrorResponseErrorCodeInvalidClientMetadata ClientInformationErrorResponseErrorCode = "invalid_client_metadata" // The value of one of the client metadata fields is invalid and the server has rejected this request. + ClientInformationErrorResponseErrorCodeInvalidSoftwareStatement ClientInformationErrorResponseErrorCode = "invalid_software_statement" // The software statement presented is invalid. + ClientInformationErrorResponseErrorCodeUnapprovedSoftwareStatement ClientInformationErrorResponseErrorCode = "unapproved_software_statement" // The software statement presented is not approved for use by this authorization server. +) + +type ClientInformationErrorResponseErrorCode string diff --git a/pkg/oidc/dynamic_client_registration_test.go b/pkg/oidc/dynamic_client_registration_test.go index 8bd6a8f2..6c2b26d7 100644 --- a/pkg/oidc/dynamic_client_registration_test.go +++ b/pkg/oidc/dynamic_client_registration_test.go @@ -2,12 +2,14 @@ package oidc import ( "encoding/json" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "testing" ) func TestClientRegistrationRequest(t *testing.T) { - t.Run("unmarshal example from https://www.rfc-editor.org/rfc/rfc7591#page-17", func(t *testing.T) { + // example from https://www.rfc-editor.org/rfc/rfc7591#page-17 + t.Run("unmarshal Client Registration Request example", func(t *testing.T) { marshalled := []byte(` { "redirect_uris": [ @@ -25,8 +27,10 @@ func TestClientRegistrationRequest(t *testing.T) { var req ClientRegistrationRequest err := json.Unmarshal(marshalled, &req) require.NoError(t, err) + assert.Len(t, req.RedirectURIs, 2) }) - t.Run("unmarshal example from https://www.rfc-editor.org/rfc/rfc7591#page-18", func(t *testing.T) { + // example from https://www.rfc-editor.org/rfc/rfc7591#page-18 + t.Run("unmarshal Client Registration Request example", func(t *testing.T) { marshalled := []byte(` { "redirect_uris": [ @@ -40,12 +44,7 @@ func TestClientRegistrationRequest(t *testing.T) { "jwks": { "keys": [{ "e": "AQAB", - "n": "nj3YJwsLUFl9BmpAbkOswCNVx17Eh9wMO-_AReZwBqfaWFcfG - HrZXsIV2VMCNVNU8Tpb4obUaSXcRcQ-VMsfQPJm9IzgtRdAY8NN8Xb7PEcYyk - lBjvTtuPbpzIaqyiUepzUXNDFuAOOkrIol3WmflPUUgMKULBN0EUd1fpOD70p - RM0rlp_gg_WNUKoW1V-3keYUJoXH9NztEDm_D2MQXj9eGOJJ8yPgGL8PAZMLe - 2R7jb9TxOCPDED7tY_TU4nFPlxptw59A42mldEmViXsKQt60s1SLboazxFKve - qXC_jpLUt22OC6GUG63p-REw-ZOr3r845z50wMuzifQrMI9bQ", + "n": "nj3YJwsLUFl9BmpAbkOswCNVx17Eh9wMO-_AReZwBqfaWFcfGHrZXsIV2VMCNVNU8Tpb4obUaSXcRcQ-VMsfQPJm9IzgtRdAY8NN8Xb7PEcYyklBjvTtuPbpzIaqyiUepzUXNDFuAOOkrIol3WmflPUUgMKULBN0EUd1fpOD70pRM0rlp_gg_WNUKoW1V-3keYUJoXH9NztEDm_D2MQXj9eGOJJ8yPgGL8PAZMLe2R7jb9TxOCPDED7tY_TU4nFPlxptw59A42mldEmViXsKQt60s1SLboazxFKveqXC_jpLUt22OC6GUG63p-REw-ZOr3r845z50wMuzifQrMI9bQ", "kty": "RSA" }] }, @@ -56,23 +55,15 @@ func TestClientRegistrationRequest(t *testing.T) { err := json.Unmarshal(marshalled, &req) require.NoError(t, err) }) - t.Run("unmarshal example from https://www.rfc-editor.org/rfc/rfc7591#page-19", func(t *testing.T) { + // from https://www.rfc-editor.org/rfc/rfc7591#page-19 + t.Run("unmarshal Client Registration Request example", func(t *testing.T) { marshalled := []byte(` { "redirect_uris": [ "https://client.example.org/callback", "https://client.example.org/callback2" ], - "software_statement": "eyJhbGciOiJSUzI1NiJ9. - eyJzb2Z0d2FyZV9pZCI6IjROUkIxLTBYWkFCWkk5RTYtNVNNM1IiLCJjbGll - bnRfbmFtZSI6IkV4YW1wbGUgU3RhdGVtZW50LWJhc2VkIENsaWVudCIsImNs - aWVudF91cmkiOiJodHRwczovL2NsaWVudC5leGFtcGxlLm5ldC8ifQ. - GHfL4QNIrQwL18BSRdE595T9jbzqa06R9BT8w409x9oIcKaZo_mt15riEXHa - zdISUvDIZhtiyNrSHQ8K4TvqWxH6uJgcmoodZdPwmWRIEYbQDLqPNxREtYn0 - 5X3AR7ia4FRjQ2ojZjk5fJqJdQ-JcfxyhK-P8BAWBd6I2LLA77IG32xtbhxY - fHX7VhuU5ProJO8uvu3Ayv4XRhLZJY4yKfmyjiiKiPNe-Ia4SMy_d_QSWxsk - U5XIQl5Sa2YRPMbDRXttm2TfnZM1xx70DoYi8g6czz-CPGRi4SW_S2RKHIJf - IjoI3zTJ0Y2oe0_EJAiXbL6OyF9S5tKxDXV8JIndSA", + "software_statement": "eyJhbGciOiJSUzI1NiJ9.eyJzb2Z0d2FyZV9pZCI6IjROUkIxLTBYWkFCWkk5RTYtNVNNM1IiLCJjbGllbnRfbmFtZSI6IkV4YW1wbGUgU3RhdGVtZW50LWJhc2VkIENsaWVudCIsImNsaWVudF91cmkiOiJodHRwczovL2NsaWVudC5leGFtcGxlLm5ldC8ifQ.GHfL4QNIrQwL18BSRdE595T9jbzqa06R9BT8w409x9oIcKaZo_mt15riEXHazdISUvDIZhtiyNrSHQ8K4TvqWxH6uJgcmoodZdPwmWRIEYbQDLqPNxREtYn05X3AR7ia4FRjQ2ojZjk5fJqJdQ-JcfxyhK-P8BAWBd6I2LLA77IG32xtbhxYfHX7VhuU5ProJO8uvu3Ayv4XRhLZJY4yKfmyjiiKiPNe-Ia4SMy_d_QSWxskU5XIQl5Sa2YRPMbDRXttm2TfnZM1xx70DoYi8g6czz-CPGRi4SW_S2RKHIJfIjoI3zTJ0Y2oe0_EJAiXbL6OyF9S5tKxDXV8JIndSA", "scope": "read write", "example_extension_parameter": "example_value" } @@ -81,4 +72,114 @@ func TestClientRegistrationRequest(t *testing.T) { err := json.Unmarshal(marshalled, &req) require.NoError(t, err) }) + // from https://www.rfc-editor.org/rfc/rfc7592.html#page-7 + t.Run("unmarshal Client Update Request example", func(t *testing.T) { + marshalled := []byte(` +{ + "client_id": "s6BhdRkqt3", + "client_secret": "cf136dc3c1fc93f31185e5885805d", + "redirect_uris": [ + "https://client.example.org/callback", + "https://client.example.org/alt" + ], + "grant_types": ["authorization_code", "refresh_token"], + "token_endpoint_auth_method": "client_secret_basic", + "jwks_uri": "https://client.example.org/my_public_keys.jwks", + "client_name": "My New Example", + "client_name#fr": "Mon Nouvel Exemple", + "logo_uri": "https://client.example.org/newlogo.png", + "logo_uri#fr": "https://client.example.org/fr/newlogo.png" +} +`) + var req ClientRegistrationRequest + err := json.Unmarshal(marshalled, &req) + require.NoError(t, err) + }) +} + +func TestClientInformationResponse(t *testing.T) { + // example from https://www.rfc-editor.org/rfc/rfc7591#page-21 + t.Run("unmarshal example", func(t *testing.T) { + marshalled := []byte(` +{ + "client_id": "s6BhdRkqt3", + "client_secret": "cf136dc3c1fc93f31185e5885805d", + "client_id_issued_at": 2893256800, + "client_secret_expires_at": 2893276800, + "redirect_uris": [ + "https://client.example.org/callback", + "https://client.example.org/callback2" + ], + "grant_types": ["authorization_code", "refresh_token"], + "client_name": "My Example Client", + "client_name#ja-Jpan-JP": "\u30AF\u30E9\u30A4\u30A2\u30F3\u30C8\u540D", + "token_endpoint_auth_method": "client_secret_basic", + "logo_uri": "https://client.example.org/logo.png", + "jwks_uri": "https://client.example.org/my_public_keys.jwks", + "example_extension_parameter": "example_value" +} +`) + var req ClientInformationResponse + err := json.Unmarshal(marshalled, &req) + require.NoError(t, err) + }) + + // example fromhttps://www.rfc-editor.org/rfc/rfc7592.html#page-11 + t.Run("unmarshal example", func(t *testing.T) { + marshalled := []byte(` +{ + "registration_access_token": "reg-23410913-abewfq.123483", + "registration_client_uri": + "https://server.example.com/register/s6BhdRkqt3", + "client_id": "s6BhdRkqt3", + "client_secret": "cf136dc3c1fc93f31185e5885805d", + "client_id_issued_at": 2893256800, + "client_secret_expires_at": 2893276800, + "client_name": "My Example Client", + "client_name#ja-Jpan-JP": "\u30AF\u30E9\u30A4\u30A2\u30F3\u30C8\u540D", + "redirect_uris": [ + "https://client.example.org/callback", + "https://client.example.org/callback2" + ], + "grant_types": ["authorization_code", "refresh_token"], + "token_endpoint_auth_method": "client_secret_basic", + "logo_uri": "https://client.example.org/logo.png", + "jwks_uri": "https://client.example.org/my_public_keys.jwks" +} +`) + var req ClientInformationResponse + err := json.Unmarshal(marshalled, &req) + require.NoError(t, err) + }) +} + +func TestClientInformationErrorResponse(t *testing.T) { + // example from https://www.rfc-editor.org/rfc/rfc7591#page-23 + t.Run("unmarshal example", func(t *testing.T) { + marshalled := []byte(` +{ + "error": "invalid_redirect_uri", + "error_description": "The redirection URI http://sketchy.example.com is not allowed by this server." +} +`) + var req ClientInformationErrorResponse + err := json.Unmarshal(marshalled, &req) + require.NoError(t, err) + assert.Equal(t, ClientInformationErrorResponseErrorCodeInvalidRedirectURI, req.Error) + assert.Equal(t, "The redirection URI http://sketchy.example.com is not allowed by this server.", req.ErrorDescription) + }) + // example from https://www.rfc-editor.org/rfc/rfc7591#page-23 + t.Run("unmarshal example", func(t *testing.T) { + marshalled := []byte(` +{ + "error": "invalid_client_metadata", + "error_description": "The grant type 'authorization_code' must be registered along with the response type 'code' but found only 'implicit' instead." +} +`) + var req ClientInformationErrorResponse + err := json.Unmarshal(marshalled, &req) + require.NoError(t, err) + assert.Equal(t, ClientInformationErrorResponseErrorCodeInvalidClientMetadata, req.Error) + assert.Equal(t, "The grant type 'authorization_code' must be registered along with the response type 'code' but found only 'implicit' instead.", req.ErrorDescription) + }) } From 96049783ce4b462239cc64ccc2767bca00759cdd Mon Sep 17 00:00:00 2001 From: mqf20 Date: Fri, 25 Jul 2025 20:37:37 +0800 Subject: [PATCH 05/44] WIP added TODO Signed-off-by: mqf20 --- pkg/oidc/dynamic_client_registration.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/oidc/dynamic_client_registration.go b/pkg/oidc/dynamic_client_registration.go index 7ed6733b..ff17a6dd 100644 --- a/pkg/oidc/dynamic_client_registration.go +++ b/pkg/oidc/dynamic_client_registration.go @@ -10,6 +10,8 @@ import ( // // Can also be used for https://www.rfc-editor.org/rfc/rfc7592.html#section-2.1 // 2.2 Client Update Request. +// +// TODO: handle BCP 47 type ClientRegistrationRequest struct { RedirectURIs []string `json:"redirect_uris"` // Array of redirection URI strings for use in redirect-based flows such as the authorization code and implicit flows. TokenEndpointAuthMethod AuthMethod `json:"token_endpoint_auth_method"` // String indicator of the requested authentication method for the token endpoint. From 253ed4ef27f44bf8d164c8c5e8d5a818e2a9ad5d Mon Sep 17 00:00:00 2001 From: mqf20 Date: Fri, 25 Jul 2025 20:47:49 +0800 Subject: [PATCH 06/44] WIP added ClientUpdateRequest Signed-off-by: mqf20 --- pkg/oidc/dynamic_client_registration.go | 23 ++++++++++++++++++++ pkg/oidc/dynamic_client_registration_test.go | 7 ++++-- pkg/op/dynamic_client_registration.go | 4 ++-- 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/pkg/oidc/dynamic_client_registration.go b/pkg/oidc/dynamic_client_registration.go index ff17a6dd..f5f09da6 100644 --- a/pkg/oidc/dynamic_client_registration.go +++ b/pkg/oidc/dynamic_client_registration.go @@ -77,3 +77,26 @@ const ( ) type ClientInformationErrorResponseErrorCode string + +// ClientUpdateRequest implements https://www.rfc-editor.org/rfc/rfc7592.html#section-2.1 +// 2.2 Client Update Request. +// +// TODO: handle BCP 47 +type ClientUpdateRequest struct { + ClientID string `json:"client_id"` + ClientRegistrationRequest +} + +// ClientReadRequest implements +// https://www.rfc-editor.org/rfc/rfc7592.html#section-2.1 +// 2.1 Client Read Request. +type ClientReadRequest struct { + ClientID string +} + +// ClientDeleteRequest implements +// https://www.rfc-editor.org/rfc/rfc7592.html#section-2.3 +// 2.3 Client Delete Request. +type ClientDeleteRequest struct { + ClientID string +} diff --git a/pkg/oidc/dynamic_client_registration_test.go b/pkg/oidc/dynamic_client_registration_test.go index 6c2b26d7..7a011c25 100644 --- a/pkg/oidc/dynamic_client_registration_test.go +++ b/pkg/oidc/dynamic_client_registration_test.go @@ -72,6 +72,9 @@ func TestClientRegistrationRequest(t *testing.T) { err := json.Unmarshal(marshalled, &req) require.NoError(t, err) }) +} + +func TestClientUpdateRequest(t *testing.T) { // from https://www.rfc-editor.org/rfc/rfc7592.html#page-7 t.Run("unmarshal Client Update Request example", func(t *testing.T) { marshalled := []byte(` @@ -91,7 +94,7 @@ func TestClientRegistrationRequest(t *testing.T) { "logo_uri#fr": "https://client.example.org/fr/newlogo.png" } `) - var req ClientRegistrationRequest + var req ClientUpdateRequest err := json.Unmarshal(marshalled, &req) require.NoError(t, err) }) @@ -124,7 +127,7 @@ func TestClientInformationResponse(t *testing.T) { require.NoError(t, err) }) - // example fromhttps://www.rfc-editor.org/rfc/rfc7592.html#page-11 + // example from https://www.rfc-editor.org/rfc/rfc7592.html#page-11 t.Run("unmarshal example", func(t *testing.T) { marshalled := []byte(` { diff --git a/pkg/op/dynamic_client_registration.go b/pkg/op/dynamic_client_registration.go index e840e373..4a534516 100644 --- a/pkg/op/dynamic_client_registration.go +++ b/pkg/op/dynamic_client_registration.go @@ -115,12 +115,12 @@ func ClientUpdate(w http.ResponseWriter, r *http.Request, o OpenIDProvider) erro return nil } -func ParseClientUpdateRequest(r *http.Request, o OpenIDProvider) (*oidc.ClientUpdateRequest, error) { +func ParseClientUpdateRequest(r *http.Request, o OpenIDProvider) (*oidc.ClientRegistrationRequest, error) { ctx, span := tracer.Start(r.Context(), "ParseClientUpdateRequest") r = r.WithContext(ctx) defer span.End() - req := new(oidc.ClientUpdateRequest) + req := new(oidc.ClientRegistrationRequest) if err := o.Decoder().Decode(req, r.Form); err != nil { return nil, oidc.ErrInvalidRequest().WithDescription("cannot parse client update request").WithParent(err) } From 1ecae91d741ef0989ac10c776288a89ee234279e Mon Sep 17 00:00:00 2001 From: mqf20 Date: Fri, 25 Jul 2025 20:48:06 +0800 Subject: [PATCH 07/44] WIP updated to use ClientUpdateRequest Signed-off-by: mqf20 --- pkg/op/dynamic_client_registration.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/op/dynamic_client_registration.go b/pkg/op/dynamic_client_registration.go index 4a534516..e840e373 100644 --- a/pkg/op/dynamic_client_registration.go +++ b/pkg/op/dynamic_client_registration.go @@ -115,12 +115,12 @@ func ClientUpdate(w http.ResponseWriter, r *http.Request, o OpenIDProvider) erro return nil } -func ParseClientUpdateRequest(r *http.Request, o OpenIDProvider) (*oidc.ClientRegistrationRequest, error) { +func ParseClientUpdateRequest(r *http.Request, o OpenIDProvider) (*oidc.ClientUpdateRequest, error) { ctx, span := tracer.Start(r.Context(), "ParseClientUpdateRequest") r = r.WithContext(ctx) defer span.End() - req := new(oidc.ClientRegistrationRequest) + req := new(oidc.ClientUpdateRequest) if err := o.Decoder().Decode(req, r.Form); err != nil { return nil, oidc.ErrInvalidRequest().WithDescription("cannot parse client update request").WithParent(err) } From b19bf9e8db934b0c2bd688b832671bcab2f7566c Mon Sep 17 00:00:00 2001 From: mqf20 Date: Sun, 27 Jul 2025 12:54:40 +0800 Subject: [PATCH 08/44] WIP added comments Signed-off-by: mqf20 --- pkg/op/storage.go | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/pkg/op/storage.go b/pkg/op/storage.go index 35d7040b..3af204d5 100644 --- a/pkg/op/storage.go +++ b/pkg/op/storage.go @@ -151,10 +151,14 @@ type CanGetPrivateClaimsFromRequest interface { GetPrivateClaimsFromRequest(ctx context.Context, request TokenRequest, restrictedScopes []string) (map[string]any, error) } -// Storage is a required parameter for NewOpenIDProvider(). In addition to the -// embedded interfaces below, if the passed Storage implements ClientCredentialsStorage -// then the grant type "client_credentials" will be supported. In that case, the access -// token returned by CreateAccessToken should be a JWT. +// Storage is a required parameter for NewOpenIDProvider(). +// +// In addition to the embedded interfaces below, +// +// - if the passed Storage implements ClientCredentialsStorage then the grant type "client_credentials" will be +// supported. In that case, the access token returned by CreateAccessToken should be a JWT. +// - if the passed Storage implemenets ClientsStorage, then dynamic client registration will be supported. +// // See https://datatracker.ietf.org/doc/html/rfc6749#section-1.3.4 for context. type Storage interface { AuthStorage @@ -200,3 +204,19 @@ func assertDeviceStorage(s Storage) (DeviceAuthorizationStorage, error) { } return storage, nil } + +// ClientsStorage is required to implement dynamic client registration. +type ClientsStorage interface { + CreateClient(ctx context.Context, c *oidc.ClientRegistrationRequest) (clientID string, err error) + ReadClient(ctx context.Context, clientID string) (*oidc.ClientInformationResponse, error) + UpdateClient(ctx context.Context, c *oidc.ClientUpdateRequest) error + DeleteClient(ctx context.Context, clientID string) error +} + +func assertClientStorage(s Storage) (ClientsStorage, error) { + storage, ok := s.(ClientsStorage) + if !ok { + return nil, oidc.ErrUnsupportedGrantType().WithDescription("Dynamic client registration not supported") + } + return storage, nil +} From 6713d71425cb1d3c29cbb2e2a98be5ea3352c5a9 Mon Sep 17 00:00:00 2001 From: mqf20 Date: Sun, 27 Jul 2025 13:05:51 +0800 Subject: [PATCH 09/44] WIP completed structure Signed-off-by: mqf20 --- pkg/op/dynamic_client_registration.go | 81 +++++++++++++++++++++++---- pkg/op/storage.go | 8 +-- 2 files changed, 74 insertions(+), 15 deletions(-) diff --git a/pkg/op/dynamic_client_registration.go b/pkg/op/dynamic_client_registration.go index e840e373..55577410 100644 --- a/pkg/op/dynamic_client_registration.go +++ b/pkg/op/dynamic_client_registration.go @@ -1,7 +1,9 @@ package op import ( + "errors" "fmt" + "github.com/go-jose/go-jose/v4/json" httphelper "github.com/zitadel/oidc/v3/pkg/http" "github.com/zitadel/oidc/v3/pkg/oidc" "net/http" @@ -42,22 +44,40 @@ func ClientRegistration(w http.ResponseWriter, r *http.Request, o OpenIDProvider r = r.WithContext(ctx) defer span.End() - req, err := ParseClientRegistrationRequest(r, o) + req, err := ParseClientRegistrationRequest(r) if err != nil { + // TODO(mqf20): be able to return the proper error codes? return err } - _ = req - httphelper.MarshalJSON(w, oidc.ClientInformationResponse{}) + + storage, err := assertClientStorage(o.Storage()) + if err != nil { + return errors.New("dynamic client registration unsupported") + } + + clientID, err := storage.RegisterClient(ctx, req) + if err != nil { + // TODO(mqf20): be able to return the proper error codes? + return err + } + + res, err := storage.ReadClient(ctx, clientID) + if err != nil { + // TODO(mqf20): be able to return the proper error codes? + return err + } + + httphelper.MarshalJSON(w, res) return nil } -func ParseClientRegistrationRequest(r *http.Request, o OpenIDProvider) (*oidc.ClientRegistrationRequest, error) { +func ParseClientRegistrationRequest(r *http.Request) (*oidc.ClientRegistrationRequest, error) { ctx, span := tracer.Start(r.Context(), "ParseClientRegistrationRequest") r = r.WithContext(ctx) defer span.End() req := new(oidc.ClientRegistrationRequest) - if err := o.Decoder().Decode(req, r.Form); err != nil { + if err := json.NewDecoder(r.Body).Decode(req); err != nil { return nil, oidc.ErrInvalidRequest().WithDescription("cannot parse client registration request").WithParent(err) } @@ -76,10 +96,22 @@ func ClientRead(w http.ResponseWriter, r *http.Request, o OpenIDProvider) error req, err := ParseClientReadRequest(r, o) if err != nil { + // TODO(mqf20): be able to return the proper error codes? + return err + } + + storage, err := assertClientStorage(o.Storage()) + if err != nil { + return errors.New("dynamic client registration unsupported") + } + + res, err := storage.ReadClient(r.Context(), req.ClientID) + if err != nil { + // TODO(mqf20): be able to return the proper error codes? return err } - _ = req - httphelper.MarshalJSON(w, oidc.ClientInformationResponse{}) + + httphelper.MarshalJSON(w, res) return nil } @@ -108,10 +140,27 @@ func ClientUpdate(w http.ResponseWriter, r *http.Request, o OpenIDProvider) erro req, err := ParseClientUpdateRequest(r, o) if err != nil { + // TODO(mqf20): be able to return the proper error codes? + return err + } + + storage, err := assertClientStorage(o.Storage()) + if err != nil { + return errors.New("dynamic client registration unsupported") + } + + if err := storage.UpdateClient(ctx, req); err != nil { + // TODO(mqf20): be able to return the proper error codes? return err } - _ = req - httphelper.MarshalJSON(w, oidc.ClientInformationResponse{}) + + res, err := storage.ReadClient(ctx, req.ClientID) + if err != nil { + // TODO(mqf20): be able to return the proper error codes? + return err + } + + httphelper.MarshalJSON(w, res) return nil } @@ -140,10 +189,20 @@ func ClientDelete(w http.ResponseWriter, r *http.Request, o OpenIDProvider) erro req, err := ParseClientDeleteRequest(r, o) if err != nil { + // TODO(mqf20): be able to return the proper error codes? return err } - _ = req - httphelper.MarshalJSON(w, oidc.ClientInformationResponse{}) + + storage, err := assertClientStorage(o.Storage()) + if err != nil { + return errors.New("dynamic client registration unsupported") + } + + if err := storage.DeleteClient(ctx, req.ClientID); err != nil { + // TODO(mqf20): be able to return the proper error codes? + return err + } + return nil } diff --git a/pkg/op/storage.go b/pkg/op/storage.go index 3af204d5..0964f687 100644 --- a/pkg/op/storage.go +++ b/pkg/op/storage.go @@ -155,9 +155,9 @@ type CanGetPrivateClaimsFromRequest interface { // // In addition to the embedded interfaces below, // -// - if the passed Storage implements ClientCredentialsStorage then the grant type "client_credentials" will be -// supported. In that case, the access token returned by CreateAccessToken should be a JWT. -// - if the passed Storage implemenets ClientsStorage, then dynamic client registration will be supported. +// - if the passed Storage implements ClientCredentialsStorage then the grant type "client_credentials" will be +// supported. In that case, the access token returned by CreateAccessToken should be a JWT. +// - if the passed Storage implemenets ClientsStorage, then dynamic client registration will be supported. // // See https://datatracker.ietf.org/doc/html/rfc6749#section-1.3.4 for context. type Storage interface { @@ -207,7 +207,7 @@ func assertDeviceStorage(s Storage) (DeviceAuthorizationStorage, error) { // ClientsStorage is required to implement dynamic client registration. type ClientsStorage interface { - CreateClient(ctx context.Context, c *oidc.ClientRegistrationRequest) (clientID string, err error) + RegisterClient(ctx context.Context, c *oidc.ClientRegistrationRequest) (clientID string, err error) ReadClient(ctx context.Context, clientID string) (*oidc.ClientInformationResponse, error) UpdateClient(ctx context.Context, c *oidc.ClientUpdateRequest) error DeleteClient(ctx context.Context, clientID string) error From fa0684bebc5e8aa86a90e9d77572827a95b5d56e Mon Sep 17 00:00:00 2001 From: mqf20 Date: Sun, 27 Jul 2025 13:11:14 +0800 Subject: [PATCH 10/44] WIP completed example Signed-off-by: mqf20 --- example/server/storage/storage.go | 85 +++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/example/server/storage/storage.go b/example/server/storage/storage.go index fee34c5e..1690d695 100644 --- a/example/server/storage/storage.go +++ b/example/server/storage/storage.go @@ -31,6 +31,7 @@ var serviceKey1 = &rsa.PublicKey{ var ( _ op.Storage = &Storage{} _ op.ClientCredentialsStorage = &Storage{} + _ op.ClientsStorage = &Storage{} ) // storage implements the op.Storage interface @@ -931,3 +932,87 @@ func (s *Storage) ClientCredentialsTokenRequest(ctx context.Context, clientID st Scopes: scopes, }, nil } + +func (s *Storage) RegisterClient(_ context.Context, c *oidc.ClientRegistrationRequest) (string, error) { + s.lock.Lock() + defer s.lock.Unlock() + client := Client{ + id: uuid.New().String(), + secret: uuid.New().String(), + redirectURIs: c.RedirectURIs, + applicationType: 0, + authMethod: c.TokenEndpointAuthMethod, + loginURL: nil, + responseTypes: c.ResponseTypes, + grantTypes: c.GrantTypes, + accessTokenType: 0, + devMode: false, + idTokenUserinfoClaimsAssertion: false, + clockSkew: 0, + postLogoutRedirectURIGlobs: nil, + redirectURIGlobs: nil, + } + s.clients[client.id] = &client + + return client.id, nil +} + +func (s *Storage) ReadClient(_ context.Context, clientID string) (*oidc.ClientInformationResponse, error) { + s.lock.Lock() + defer s.lock.Unlock() + client, ok := s.clients[clientID] + if !ok { + return nil, errors.New("client not found") + } + return &oidc.ClientInformationResponse{ + ClientID: client.id, + ClientSecret: client.secret, + //ClientIDIssuedAt: 0, + //ClientSecretExpiresAt: 0, + RedirectURIs: client.RedirectURIs(), + TokenEndpointAuthMethod: client.AuthMethod(), + GrantTypes: client.GrantTypes(), + ResponseTypes: client.ResponseTypes(), + //ClientName: "", + //ClientURI: "", + //LogoURI: "", + Scope: "", + Contacts: nil, + TOSURI: "", + PolicyURI: "", + JWKSURI: "", + JWKS: jose.JSONWebKeySet{}, + SoftwareID: "", + SoftwareVersion: "", + }, nil +} + +func (s *Storage) UpdateClient(_ context.Context, c *oidc.ClientUpdateRequest) error { + s.lock.Lock() + defer s.lock.Unlock() + client, ok := s.clients[c.ClientID] + if !ok { + return errors.New("client not found") + } + client.redirectURIs = c.RedirectURIs + client.applicationType = 0 + client.authMethod = c.TokenEndpointAuthMethod + client.loginURL = nil + client.responseTypes = c.ResponseTypes + client.grantTypes = c.GrantTypes + client.accessTokenType = 0 + client.devMode = false + client.idTokenUserinfoClaimsAssertion = false + client.clockSkew = 0 + client.postLogoutRedirectURIGlobs = nil + client.redirectURIGlobs = nil + return nil +} + +func (s *Storage) DeleteClient(_ context.Context, clientID string) error { + s.lock.Lock() + defer s.lock.Unlock() + // TODO(mqf20): If possible, the authorization server SHOULD immediately invalidate all existing authorization grants and currently active access tokens, all refresh tokens, and all other tokens associated with this client. + delete(s.clients, clientID) + return nil +} From 38de4e1adfdffd07648c61d61efdc579a0ece6c9 Mon Sep 17 00:00:00 2001 From: mqf20 Date: Sun, 27 Jul 2025 23:22:49 +0800 Subject: [PATCH 11/44] WIP added comments Signed-off-by: mqf20 --- pkg/op/storage.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/pkg/op/storage.go b/pkg/op/storage.go index 0964f687..a123c205 100644 --- a/pkg/op/storage.go +++ b/pkg/op/storage.go @@ -207,9 +207,21 @@ func assertDeviceStorage(s Storage) (DeviceAuthorizationStorage, error) { // ClientsStorage is required to implement dynamic client registration. type ClientsStorage interface { + // RegisterClient handles the Client Registration Request according to [RFC7591]. + // + // [RFC7591]: https://www.rfc-editor.org/rfc/rfc7591#section-3.1 RegisterClient(ctx context.Context, c *oidc.ClientRegistrationRequest) (clientID string, err error) + // ReadClient handles the Client Read Request according to [RFC7592]. + // + // [RFC7592]: https://www.rfc-editor.org/rfc/rfc7592.html#section-2.1 ReadClient(ctx context.Context, clientID string) (*oidc.ClientInformationResponse, error) + // UpdateClient handles the Client Update Request according to [RFC7592]. + // + // [RFC7592]: https://www.rfc-editor.org/rfc/rfc7592.html#section-2.2 UpdateClient(ctx context.Context, c *oidc.ClientUpdateRequest) error + // DeleteClient handles the Client Delete Request according to [RFC7592]. + // + // [RFC7592]: https://www.rfc-editor.org/rfc/rfc7592.html#section-2.3 DeleteClient(ctx context.Context, clientID string) error } From dd0f251da24c84b3b2f90aa1c76f1c827bfbd7a8 Mon Sep 17 00:00:00 2001 From: mqf20 Date: Sun, 27 Jul 2025 22:23:11 +0700 Subject: [PATCH 12/44] WIP added correct return code Signed-off-by: mqf20 --- pkg/op/dynamic_client_registration.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/op/dynamic_client_registration.go b/pkg/op/dynamic_client_registration.go index 55577410..2be2074a 100644 --- a/pkg/op/dynamic_client_registration.go +++ b/pkg/op/dynamic_client_registration.go @@ -203,6 +203,7 @@ func ClientDelete(w http.ResponseWriter, r *http.Request, o OpenIDProvider) erro return err } + w.WriteHeader(http.StatusNoContent) return nil } From fe7a3d9f97f51e1e47e30db076b8882ddc0e20b0 Mon Sep 17 00:00:00 2001 From: mqf20 Date: Mon, 28 Jul 2025 22:35:28 +0700 Subject: [PATCH 13/44] WIP updated example Signed-off-by: mqf20 --- example/server/storage/storage.go | 54 ++++++++++++++++++++----------- 1 file changed, 35 insertions(+), 19 deletions(-) diff --git a/example/server/storage/storage.go b/example/server/storage/storage.go index 1690d695..9efb53fa 100644 --- a/example/server/storage/storage.go +++ b/example/server/storage/storage.go @@ -965,25 +965,33 @@ func (s *Storage) ReadClient(_ context.Context, clientID string) (*oidc.ClientIn return nil, errors.New("client not found") } return &oidc.ClientInformationResponse{ - ClientID: client.id, - ClientSecret: client.secret, + ClientID: client.id, + ClientSecret: client.secret, + ClientIDIssuedAt: 0, + ClientSecretExpiresAt: 0, //ClientIDIssuedAt: 0, //ClientSecretExpiresAt: 0, RedirectURIs: client.RedirectURIs(), TokenEndpointAuthMethod: client.AuthMethod(), GrantTypes: client.GrantTypes(), ResponseTypes: client.ResponseTypes(), + ClientName: nil, + ClientURI: nil, + LogoURI: nil, //ClientName: "", //ClientURI: "", //LogoURI: "", - Scope: "", - Contacts: nil, - TOSURI: "", - PolicyURI: "", - JWKSURI: "", - JWKS: jose.JSONWebKeySet{}, - SoftwareID: "", - SoftwareVersion: "", + Scope: "", + Contacts: nil, + TOSURI: nil, + PolicyURI: nil, + JWKSURI: "", + JWKS: jose.JSONWebKeySet{}, + SoftwareID: "", + SoftwareVersion: "", + RegistrationAccessToken: "", + RegistrationClientURI: "", + ExtraParameters: nil, }, nil } @@ -994,18 +1002,26 @@ func (s *Storage) UpdateClient(_ context.Context, c *oidc.ClientUpdateRequest) e if !ok { return errors.New("client not found") } + //client.id = "" + client.secret = c.ClientSecret + //client.??? = c.ClientIDIssuedAt + //client.??? = c.ClientSecretExpiresAt client.redirectURIs = c.RedirectURIs - client.applicationType = 0 client.authMethod = c.TokenEndpointAuthMethod - client.loginURL = nil - client.responseTypes = c.ResponseTypes client.grantTypes = c.GrantTypes - client.accessTokenType = 0 - client.devMode = false - client.idTokenUserinfoClaimsAssertion = false - client.clockSkew = 0 - client.postLogoutRedirectURIGlobs = nil - client.redirectURIGlobs = nil + client.responseTypes = c.ResponseTypes + //client.??? = c.ClientName + //client.??? = c.ClientURI + //client.??? = c.LogoURI + //client.??? = c.Scope + //client.??? = c.Contacts + //client.??? = c.TOSURI + //client.??? = c.PolicyURI + //client.??? = c.JWKSURI + //client.??? = c.JWKS + //client.??? = c.SoftwareID + //client.??? = c.SoftwareVersion + return nil } From 7970aa07118d4be52ea2e9eb32da54ca7886ec4a Mon Sep 17 00:00:00 2001 From: mqf20 Date: Mon, 28 Jul 2025 22:36:23 +0700 Subject: [PATCH 14/44] WIP fixed return code Signed-off-by: mqf20 --- pkg/op/dynamic_client_registration.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/op/dynamic_client_registration.go b/pkg/op/dynamic_client_registration.go index 2be2074a..08cffcb0 100644 --- a/pkg/op/dynamic_client_registration.go +++ b/pkg/op/dynamic_client_registration.go @@ -67,7 +67,7 @@ func ClientRegistration(w http.ResponseWriter, r *http.Request, o OpenIDProvider return err } - httphelper.MarshalJSON(w, res) + httphelper.MarshalJSONWithStatus(w, res, http.StatusCreated) return nil } From 502f1cd7e64a38eb4806e69b142e125bccaf63ed Mon Sep 17 00:00:00 2001 From: mqf20 Date: Mon, 28 Jul 2025 22:37:17 +0700 Subject: [PATCH 15/44] WIP fixed request/response structs Signed-off-by: mqf20 --- pkg/oidc/authorization.go | 6 + pkg/oidc/discovery.go | 7 + pkg/oidc/dynamic_client_registration.go | 704 ++++++++++++++++++- pkg/oidc/dynamic_client_registration_test.go | 235 ++++++- pkg/oidc/token_request.go | 11 + 5 files changed, 943 insertions(+), 20 deletions(-) diff --git a/pkg/oidc/authorization.go b/pkg/oidc/authorization.go index fa37dbfe..0e8dae88 100644 --- a/pkg/oidc/authorization.go +++ b/pkg/oidc/authorization.go @@ -64,6 +64,12 @@ const ( PromptSelectAccount = "select_account" ) +var ResponseTypeMap = map[string]ResponseType{ + string(ResponseTypeCode): ResponseTypeCode, + string(ResponseTypeIDToken): ResponseTypeIDToken, + string(ResponseTypeIDTokenOnly): ResponseTypeIDTokenOnly, +} + // AuthRequest according to: // https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest type AuthRequest struct { diff --git a/pkg/oidc/discovery.go b/pkg/oidc/discovery.go index c6fd3d14..7e01a928 100644 --- a/pkg/oidc/discovery.go +++ b/pkg/oidc/discovery.go @@ -167,3 +167,10 @@ const ( var AllAuthMethods = []AuthMethod{ AuthMethodBasic, AuthMethodPost, AuthMethodNone, AuthMethodPrivateKeyJWT, } + +var AuthMethodMap = map[string]AuthMethod{ + string(AuthMethodBasic): AuthMethodBasic, + string(AuthMethodPost): AuthMethodPost, + string(AuthMethodNone): AuthMethodNone, + string(AuthMethodPrivateKeyJWT): AuthMethodPrivateKeyJWT, +} diff --git a/pkg/oidc/dynamic_client_registration.go b/pkg/oidc/dynamic_client_registration.go index f5f09da6..7a11e233 100644 --- a/pkg/oidc/dynamic_client_registration.go +++ b/pkg/oidc/dynamic_client_registration.go @@ -1,7 +1,10 @@ package oidc import ( + "encoding/json" + "fmt" "github.com/go-jose/go-jose/v4" + "strings" ) // ClientRegistrationRequest implements @@ -17,17 +20,190 @@ type ClientRegistrationRequest struct { TokenEndpointAuthMethod AuthMethod `json:"token_endpoint_auth_method"` // String indicator of the requested authentication method for the token endpoint. GrantTypes []GrantType `json:"grant_types"` // Array of OAuth 2.0 grant type strings that the client can use at the token endpoint. ResponseTypes []ResponseType `json:"response_types"` // Array of the OAuth 2.0 response type strings that the client can use at the authorization endpoint. - ClientName string `json:"client_name"` // Human-readable string name of the client to be presented to the end-user during authorization. (BCP 47) - ClientURI string `json:"client_uri"` // URL string of a web page providing information about the client. (BCP 47) - LogoURI string `json:"logo_uri"` // URL string that references a logo for the client. (BCP 47) + ClientName map[string]string `json:"client_name"` // Human-readable string name of the client to be presented to the end-user during authorization. (BCP 47) + ClientURI map[string]string `json:"client_uri"` // URL string of a web page providing information about the client. (BCP 47) + LogoURI map[string]string `json:"logo_uri"` // URL string that references a logo for the client. (BCP 47) Scope string `json:"scope"` // String containing a space-separated list of scope values (as described in Section 3.3 of OAuth 2.0 [RFC6749]) that the client can use when requesting access tokens. Contacts []string `json:"contacts"` // Array of strings representing ways to contact people responsible for this client, typically email addresses. - TOSURI string `json:"tos_uri"` // URL string that points to a human-readable terms of service document for the client that describes a contractual relationship between the end-user and the client that the end-user accepts when authorizing the client. (BCP 47) - PolicyURI string `json:"policy_uri"` // URL string that points to a human-readable privacy policy document that describes how the deployment organization collects, uses, retains, and discloses personal data. + TOSURI map[string]string `json:"tos_uri"` // URL string that points to a human-readable terms of service document for the client that describes a contractual relationship between the end-user and the client that the end-user accepts when authorizing the client. (BCP 47) + PolicyURI map[string]string `json:"policy_uri"` // URL string that points to a human-readable privacy policy document that describes how the deployment organization collects, uses, retains, and discloses personal data. (BCP 47) JWKSURI string `json:"jwks_uri"` // URL string referencing the client's JSON Web Key (JWK) Set [RFC7517] document, which contains the client's public keys. JWKS jose.JSONWebKeySet `json:"jwks"` // Client's JSON Web Key Set [RFC7517] document value, which contains the client's public keys. SoftwareID string `json:"software_id"` // A unique identifier string (e.g., a Universally Unique Identifier (UUID)) assigned by the client developer or software publisher used by registration endpoints to identify the client software to be dynamically registered. SoftwareVersion string `json:"software_version"` // A version identifier string for the client software identified by "software_id". + SoftwareStatement string `json:"software_statement"` // A software statement containing client metadata values about the client software as claims. + + // ExtraParameters holds other extension parameters. + ExtraParameters map[string]interface{} +} + +func (c *ClientRegistrationRequest) UnmarshalJSON(data []byte) error { + // Initialize maps to avoid nil pointer issues later. + c.ClientName = make(map[string]string) + c.ClientURI = make(map[string]string) + c.LogoURI = make(map[string]string) + c.TOSURI = make(map[string]string) + c.PolicyURI = make(map[string]string) + c.ExtraParameters = make(map[string]interface{}) + + // Unmarshal into a temporary map to inspect all keys. + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + return fmt.Errorf("could not unmarshal raw data: %w", err) + } + + // Iterate over all keys found in the JSON. + for key, value := range raw { + switch { + case key == "redirect_uris": + if uris, ok := value.([]interface{}); ok { + for _, u := range uris { + if uriStr, ok := u.(string); ok { + c.RedirectURIs = append(c.RedirectURIs, uriStr) + } + } + } + case key == "token_endpoint_auth_method": + if vStr, ok := value.(string); ok { + if v, exists := AuthMethodMap[vStr]; exists { + c.TokenEndpointAuthMethod = v + } + } + case key == "grant_types": + if gts, ok := value.([]interface{}); ok { + for _, gt := range gts { + if gtStr, ok := gt.(string); ok { + if gtParsed, exists := GrantTypeMap[gtStr]; exists { + c.GrantTypes = append(c.GrantTypes, gtParsed) + } + } + } + } + case key == "response_types": + if rts, ok := value.([]interface{}); ok { + for _, rt := range rts { + if rtStr, ok := rt.(string); ok { + if rtParsed, exists := ResponseTypeMap[rtStr]; exists { + c.ResponseTypes = append(c.ResponseTypes, rtParsed) + } + } + } + } + case key == "client_name": + if name, ok := value.(string); ok { + // This is the default, non-tagged name. + c.ClientName["default"] = name + } + case strings.HasPrefix(key, "client_name#"): + if name, ok := value.(string); ok { + // This is a tagged name, e.g., "client_name#ja-Jpan-JP" + // Split the key at the first '#' to get the language tag. + parts := strings.SplitN(key, "#", 2) + if len(parts) == 2 { + langTag := parts[1] + c.ClientName[langTag] = name + } + } + case key == "client_uri": + if uri, ok := value.(string); ok { + // This is the default, non-tagged name. + c.ClientURI["default"] = uri + } + case strings.HasPrefix(key, "client_uri#"): + if uri, ok := value.(string); ok { + // This is a tagged name, e.g., "client_uri#ja-Jpan-JP" + // Split the key at the first '#' to get the language tag. + parts := strings.SplitN(key, "#", 2) + if len(parts) == 2 { + langTag := parts[1] + c.ClientURI[langTag] = uri + } + } + case key == "logo_uri": + if logo, ok := value.(string); ok { + // This is the default, non-tagged name. + c.LogoURI["default"] = logo + } + case strings.HasPrefix(key, "logo_uri#"): + if logo, ok := value.(string); ok { + // This is a tagged name, e.g., "logo_uri#ja-Jpan-JP" + // Split the key at the first '#' to get the language tag. + parts := strings.SplitN(key, "#", 2) + if len(parts) == 2 { + langTag := parts[1] + c.LogoURI[langTag] = logo + } + } + case key == "scope": + if v, ok := value.(string); ok { + c.Scope = v + } + case key == "contacts": + if cts, ok := value.([]interface{}); ok { + for _, ct := range cts { + if ctStr, ok := ct.(string); ok { + c.Contacts = append(c.Contacts, ctStr) + } + } + } + case key == "tos_uri": + if uri, ok := value.(string); ok { + // This is the default, non-tagged name. + c.TOSURI["default"] = uri + } + case strings.HasPrefix(key, "tos_uri#"): + if uri, ok := value.(string); ok { + // This is a tagged name, e.g., "tos_uri#ja-Jpan-JP" + // Split the key at the first '#' to get the language tag. + parts := strings.SplitN(key, "#", 2) + if len(parts) == 2 { + langTag := parts[1] + c.TOSURI[langTag] = uri + } + } + case key == "policy_uri": + if uri, ok := value.(string); ok { + // This is the default, non-tagged name. + c.PolicyURI["default"] = uri + } + case strings.HasPrefix(key, "policy_uri#"): + if uri, ok := value.(string); ok { + // This is a tagged name, e.g., "policy_uri#ja-Jpan-JP" + // Split the key at the first '#' to get the language tag. + parts := strings.SplitN(key, "#", 2) + if len(parts) == 2 { + langTag := parts[1] + c.PolicyURI[langTag] = uri + } + } + case key == "jwks_uri": + if v, ok := value.(string); ok { + c.JWKSURI = v + } + case key == "jwks": + // unmarshal into a jose.JSONWebKeySet + if vBytes, err := json.Marshal(value); err == nil { + _ = json.Unmarshal(vBytes, &c.JWKS) + } + case key == "software_id": + if v, ok := value.(string); ok { + c.SoftwareID = v + } + case key == "software_version": + if v, ok := value.(string); ok { + c.SoftwareVersion = v + } + case key == "software_statement": + if v, ok := value.(string); ok { + c.SoftwareStatement = v + } + default: + // If the key didn't match any of the above, it's an extra parameter. + c.ExtraParameters[key] = value + } + } + + return nil } // ClientInformationResponse implements @@ -46,17 +222,313 @@ type ClientInformationResponse struct { TokenEndpointAuthMethod AuthMethod `json:"token_endpoint_auth_method,omitempty"` // String indicator of the requested authentication method for the token endpoint. GrantTypes []GrantType `json:"grant_types,omitempty"` // Array of OAuth 2.0 grant type strings that the client can use at the token endpoint. ResponseTypes []ResponseType `json:"response_types,omitempty"` // Array of the OAuth 2.0 response type strings that the client can use at the authorization endpoint. - ClientName string `json:"client_name,omitempty"` // Human-readable string name of the client to be presented to the end-user during authorization. (BCP 47) - ClientURI string `json:"client_uri,omitempty"` // URL string of a web page providing information about the client. (BCP 47) - LogoURI string `json:"logo_uri,omitempty"` // URL string that references a logo for the client. (BCP 47) + ClientName map[string]string `json:"client_name,omitempty"` // Human-readable string name of the client to be presented to the end-user during authorization. (BCP 47) + ClientURI map[string]string `json:"client_uri,omitempty"` // URL string of a web page providing information about the client. (BCP 47) + LogoURI map[string]string `json:"logo_uri,omitempty"` // URL string that references a logo for the client. (BCP 47) Scope string `json:"scope,omitempty"` // String containing a space-separated list of scope values (as described in Section 3.3 of OAuth 2.0 [RFC6749]) that the client can use when requesting access tokens. Contacts []string `json:"contacts,omitempty"` // Array of strings representing ways to contact people responsible for this client, typically email addresses. - TOSURI string `json:"tos_uri,omitempty"` // URL string that points to a human-readable terms of service document for the client that describes a contractual relationship between the end-user and the client that the end-user accepts when authorizing the client. (BCP 47) - PolicyURI string `json:"policy_uri,omitempty"` // URL string that points to a human-readable privacy policy document that describes how the deployment organization collects, uses, retains, and discloses personal data. (BCP 47) + TOSURI map[string]string `json:"tos_uri,omitempty"` // URL string that points to a human-readable terms of service document for the client that describes a contractual relationship between the end-user and the client that the end-user accepts when authorizing the client. (BCP 47) + PolicyURI map[string]string `json:"policy_uri,omitempty"` // URL string that points to a human-readable privacy policy document that describes how the deployment organization collects, uses, retains, and discloses personal data. (BCP 47) JWKSURI string `json:"jwks_uri,omitempty"` // URL string referencing the client's JSON Web Key (JWK) Set [RFC7517] document, which contains the client's public keys. JWKS jose.JSONWebKeySet `json:"jwks,omitempty"` // Client's JSON Web Key Set [RFC7517] document value, which contains the client's public keys. SoftwareID string `json:"software_id,omitempty"` // A unique identifier string (e.g., a Universally Unique Identifier (UUID)) assigned by the client developer or software publisher used by registration endpoints to identify the client software to be dynamically registered. SoftwareVersion string `json:"software_version,omitempty"` // A version identifier string for the client software identified by "software_id". + RegistrationAccessToken string `json:"registration_access_token,omitempty"` + RegistrationClientURI string `json:"registration_client_uri,omitempty"` + + // ExtraParameters holds other extension parameters. + ExtraParameters map[string]interface{} +} + +func (c ClientInformationResponse) MarshalJSON() ([]byte, error) { + res := make(map[string]interface{}) + + res["client_id"] = c.ClientID // always present + if c.ClientSecret != "" { + res["client_secret"] = c.ClientSecret + } + if c.ClientIDIssuedAt != 0 { + res["client_id_issued_at"] = c.ClientIDIssuedAt + } + if c.ClientSecretExpiresAt != 0 { + res["client_secret_expires_at"] = c.ClientSecretExpiresAt + } + + if len(c.RedirectURIs) > 0 { + res["redirect_uris"] = c.RedirectURIs + } + if c.TokenEndpointAuthMethod != "" { + res["token_endpoint_auth_method"] = c.TokenEndpointAuthMethod + } + if len(c.GrantTypes) > 0 { + res["grant_types"] = c.GrantTypes + } + if len(c.ResponseTypes) > 0 { + res["response_types"] = c.ResponseTypes + } + if len(c.ClientName) > 0 { + for lang, name := range c.ClientName { + if lang == "default" { + res["client_name"] = name + } else { + res[fmt.Sprintf("client_name#%s", lang)] = name + } + } + } + if len(c.ClientURI) > 0 { + for lang, uri := range c.ClientURI { + if lang == "default" { + res["client_uri"] = uri + } else { + res[fmt.Sprintf("client_uri#%s", lang)] = uri + } + } + } + if len(c.LogoURI) > 0 { + for lang, logo := range c.LogoURI { + if lang == "default" { + res["logo_uri"] = logo + } else { + res[fmt.Sprintf("logo_uri#%s", lang)] = logo + } + } + } + if c.Scope != "" { + res["scope"] = c.Scope + } + if len(c.Contacts) > 0 { + res["contacts"] = c.Contacts + } + if len(c.TOSURI) > 0 { + for lang, uri := range c.TOSURI { + if lang == "default" { + res["tos_uri"] = uri + } else { + res[fmt.Sprintf("tos_uri#%s", lang)] = uri + } + } + } + if len(c.PolicyURI) > 0 { + for lang, uri := range c.PolicyURI { + if lang == "default" { + res["policy_uri"] = uri + } else { + res[fmt.Sprintf("policy_uri#%s", lang)] = uri + } + } + } + if c.JWKSURI != "" { + res["jwks_uri"] = c.JWKSURI + } + if len(c.JWKS.Keys) > 0 { + res["jwks"] = c.JWKS + } + if c.SoftwareID != "" { + res["software_id"] = c.SoftwareID + } + if c.SoftwareVersion != "" { + res["software_version"] = c.SoftwareVersion + } + if c.RegistrationAccessToken != "" { + res["registration_access_token"] = c.RegistrationAccessToken + } + if c.RegistrationClientURI != "" { + res["registration_client_uri"] = c.RegistrationClientURI + } + + // Add extra parameters + for key, value := range c.ExtraParameters { + res[key] = value + } + + return json.Marshal(res) +} +func (c *ClientInformationResponse) UnmarshalJSON(data []byte) error { + // Initialize maps to avoid nil pointer issues later. + c.ClientName = make(map[string]string) + c.ClientURI = make(map[string]string) + c.LogoURI = make(map[string]string) + c.TOSURI = make(map[string]string) + c.PolicyURI = make(map[string]string) + c.ExtraParameters = make(map[string]interface{}) + + // Unmarshal into a temporary map to inspect all keys. + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + return fmt.Errorf("could not unmarshal raw data: %w", err) + } + + // Iterate over all keys found in the JSON. + for key, value := range raw { + switch { + case key == "client_id": + if v, ok := value.(string); ok { + c.ClientID = v + } + case key == "client_secret": + if v, ok := value.(string); ok { + c.ClientSecret = v + } + case key == "client_id_issued_at": + if v, ok := value.(float64); ok { + c.ClientIDIssuedAt = int64(v) + } + case key == "client_secret_expires_at": + if v, ok := value.(float64); ok { + c.ClientSecretExpiresAt = int64(v) + } + case key == "redirect_uris": + if uris, ok := value.([]interface{}); ok { + for _, u := range uris { + if uriStr, ok := u.(string); ok { + c.RedirectURIs = append(c.RedirectURIs, uriStr) + } + } + } + case key == "token_endpoint_auth_method": + if vStr, ok := value.(string); ok { + if v, exists := AuthMethodMap[vStr]; exists { + c.TokenEndpointAuthMethod = v + } + } + case key == "grant_types": + if gts, ok := value.([]interface{}); ok { + for _, gt := range gts { + if gtStr, ok := gt.(string); ok { + if gtParsed, exists := GrantTypeMap[gtStr]; exists { + c.GrantTypes = append(c.GrantTypes, gtParsed) + } + } + } + } + case key == "response_types": + if rts, ok := value.([]interface{}); ok { + for _, rt := range rts { + if rtStr, ok := rt.(string); ok { + if rtParsed, exists := ResponseTypeMap[rtStr]; exists { + c.ResponseTypes = append(c.ResponseTypes, rtParsed) + } + } + } + } + case key == "client_name": + if name, ok := value.(string); ok { + // This is the default, non-tagged name. + c.ClientName["default"] = name + } + case strings.HasPrefix(key, "client_name#"): + if name, ok := value.(string); ok { + // This is a tagged name, e.g., "client_name#ja-Jpan-JP" + // Split the key at the first '#' to get the language tag. + parts := strings.SplitN(key, "#", 2) + if len(parts) == 2 { + langTag := parts[1] + c.ClientName[langTag] = name + } + } + case key == "client_uri": + if uri, ok := value.(string); ok { + // This is the default, non-tagged name. + c.ClientURI["default"] = uri + } + case strings.HasPrefix(key, "client_uri#"): + if uri, ok := value.(string); ok { + // This is a tagged name, e.g., "client_uri#ja-Jpan-JP" + // Split the key at the first '#' to get the language tag. + parts := strings.SplitN(key, "#", 2) + if len(parts) == 2 { + langTag := parts[1] + c.ClientURI[langTag] = uri + } + } + case key == "logo_uri": + if logo, ok := value.(string); ok { + // This is the default, non-tagged name. + c.LogoURI["default"] = logo + } + case strings.HasPrefix(key, "logo_uri#"): + if logo, ok := value.(string); ok { + // This is a tagged name, e.g., "logo_uri#ja-Jpan-JP" + // Split the key at the first '#' to get the language tag. + parts := strings.SplitN(key, "#", 2) + if len(parts) == 2 { + langTag := parts[1] + c.LogoURI[langTag] = logo + } + } + case key == "scope": + if v, ok := value.(string); ok { + c.Scope = v + } + case key == "contacts": + if cts, ok := value.([]interface{}); ok { + for _, ct := range cts { + if ctStr, ok := ct.(string); ok { + c.Contacts = append(c.Contacts, ctStr) + } + } + } + case key == "tos_uri": + if uri, ok := value.(string); ok { + // This is the default, non-tagged name. + c.TOSURI["default"] = uri + } + case strings.HasPrefix(key, "tos_uri#"): + if uri, ok := value.(string); ok { + // This is a tagged name, e.g., "tos_uri#ja-Jpan-JP" + // Split the key at the first '#' to get the language tag. + parts := strings.SplitN(key, "#", 2) + if len(parts) == 2 { + langTag := parts[1] + c.TOSURI[langTag] = uri + } + } + case key == "policy_uri": + if uri, ok := value.(string); ok { + // This is the default, non-tagged name. + c.PolicyURI["default"] = uri + } + case strings.HasPrefix(key, "policy_uri#"): + if uri, ok := value.(string); ok { + // This is a tagged name, e.g., "policy_uri#ja-Jpan-JP" + // Split the key at the first '#' to get the language tag. + parts := strings.SplitN(key, "#", 2) + if len(parts) == 2 { + langTag := parts[1] + c.PolicyURI[langTag] = uri + } + } + case key == "jwks_uri": + if v, ok := value.(string); ok { + c.JWKSURI = v + } + case key == "jwks": + if v, ok := value.(jose.JSONWebKeySet); ok { + c.JWKS = v + } + case key == "software_id": + if v, ok := value.(string); ok { + c.SoftwareID = v + } + case key == "software_version": + if v, ok := value.(string); ok { + c.SoftwareVersion = v + } + case key == "registration_access_token": + if v, ok := value.(string); ok { + c.RegistrationAccessToken = v + } + case key == "registration_client_uri": + if v, ok := value.(string); ok { + c.RegistrationClientURI = v + } + default: + // If the key didn't match any of the above, it's an extra parameter. + c.ExtraParameters[key] = value + } + } + + return nil } // ClientInformationErrorResponse implements @@ -81,10 +553,216 @@ type ClientInformationErrorResponseErrorCode string // ClientUpdateRequest implements https://www.rfc-editor.org/rfc/rfc7592.html#section-2.1 // 2.2 Client Update Request. // -// TODO: handle BCP 47 +// Similar to ClientInformationResponse, except: +// +// This request MUST include all client metadata fields as returned to +// the client from a previous registration, read, or update operation. +// The updated client metadata fields request MUST NOT include the +// "registration_access_token", "registration_client_uri", +// "client_secret_expires_at", or "client_id_issued_at" fields described +// in Section 3. type ClientUpdateRequest struct { - ClientID string `json:"client_id"` - ClientRegistrationRequest + ClientID string `json:"client_id"` // OAuth 2.0 client identifier string. + ClientSecret string `json:"client_secret,omitempty"` // OAuth 2.0 client secret string. + //ClientIDIssuedAt int64 `json:"client_id_issued_at,omitempty"` // Time at which the client identifier was issued. + //ClientSecretExpiresAt int64 `json:"client_secret_expires_at,omitempty"` // Time at which the client secret will expire or 0 if it will not expire. + + // fields that are reused from ClientRegistrationRequest + RedirectURIs []string `json:"redirect_uris,omitempty"` // Array of redirection URI strings for use in redirect-based flows such as the authorization code and implicit flows. + TokenEndpointAuthMethod AuthMethod `json:"token_endpoint_auth_method,omitempty"` // String indicator of the requested authentication method for the token endpoint. + GrantTypes []GrantType `json:"grant_types,omitempty"` // Array of OAuth 2.0 grant type strings that the client can use at the token endpoint. + ResponseTypes []ResponseType `json:"response_types,omitempty"` // Array of the OAuth 2.0 response type strings that the client can use at the authorization endpoint. + ClientName map[string]string `json:"client_name,omitempty"` // Human-readable string name of the client to be presented to the end-user during authorization. (BCP 47) + ClientURI map[string]string `json:"client_uri,omitempty"` // URL string of a web page providing information about the client. (BCP 47) + LogoURI map[string]string `json:"logo_uri,omitempty"` // URL string that references a logo for the client. (BCP 47) + Scope string `json:"scope,omitempty"` // String containing a space-separated list of scope values (as described in Section 3.3 of OAuth 2.0 [RFC6749]) that the client can use when requesting access tokens. + Contacts []string `json:"contacts,omitempty"` // Array of strings representing ways to contact people responsible for this client, typically email addresses. + TOSURI map[string]string `json:"tos_uri,omitempty"` // URL string that points to a human-readable terms of service document for the client that describes a contractual relationship between the end-user and the client that the end-user accepts when authorizing the client. (BCP 47) + PolicyURI map[string]string `json:"policy_uri,omitempty"` // URL string that points to a human-readable privacy policy document that describes how the deployment organization collects, uses, retains, and discloses personal data. (BCP 47) + JWKSURI string `json:"jwks_uri,omitempty"` // URL string referencing the client's JSON Web Key (JWK) Set [RFC7517] document, which contains the client's public keys. + JWKS jose.JSONWebKeySet `json:"jwks,omitempty"` // Client's JSON Web Key Set [RFC7517] document value, which contains the client's public keys. + SoftwareID string `json:"software_id,omitempty"` // A unique identifier string (e.g., a Universally Unique Identifier (UUID)) assigned by the client developer or software publisher used by registration endpoints to identify the client software to be dynamically registered. + SoftwareVersion string `json:"software_version,omitempty"` // A version identifier string for the client software identified by "software_id". + //RegistrationAccessToken string `json:"registration_access_token,omitempty"` + //RegistrationClientURI string `json:"registration_client_uri,omitempty"` + + // ExtraParameters holds other extension parameters. + ExtraParameters map[string]interface{} +} + +// UnmarshalJSON +// +// TODO: collapse with ClientInformationResponse.UnmarshalJSON +func (c *ClientUpdateRequest) UnmarshalJSON(data []byte) error { + // Initialize maps to avoid nil pointer issues later. + c.ClientName = make(map[string]string) + c.ClientURI = make(map[string]string) + c.LogoURI = make(map[string]string) + c.TOSURI = make(map[string]string) + c.PolicyURI = make(map[string]string) + c.ExtraParameters = make(map[string]interface{}) + + // Unmarshal into a temporary map to inspect all keys. + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + return fmt.Errorf("could not unmarshal raw data: %w", err) + } + + // Iterate over all keys found in the JSON. + for key, value := range raw { + switch { + case key == "client_id": + if v, ok := value.(string); ok { + c.ClientID = v + } + case key == "client_secret": + if v, ok := value.(string); ok { + c.ClientSecret = v + } + case key == "redirect_uris": + if uris, ok := value.([]interface{}); ok { + for _, u := range uris { + if uriStr, ok := u.(string); ok { + c.RedirectURIs = append(c.RedirectURIs, uriStr) + } + } + } + case key == "token_endpoint_auth_method": + if vStr, ok := value.(string); ok { + if v, exists := AuthMethodMap[vStr]; exists { + c.TokenEndpointAuthMethod = v + } + } + case key == "grant_types": + if gts, ok := value.([]interface{}); ok { + for _, gt := range gts { + if gtStr, ok := gt.(string); ok { + if gtParsed, exists := GrantTypeMap[gtStr]; exists { + c.GrantTypes = append(c.GrantTypes, gtParsed) + } + } + } + } + case key == "response_types": + if rts, ok := value.([]interface{}); ok { + for _, rt := range rts { + if rtStr, ok := rt.(string); ok { + if rtParsed, exists := ResponseTypeMap[rtStr]; exists { + c.ResponseTypes = append(c.ResponseTypes, rtParsed) + } + } + } + } + case key == "client_name": + if name, ok := value.(string); ok { + // This is the default, non-tagged name. + c.ClientName["default"] = name + } + case strings.HasPrefix(key, "client_name#"): + if name, ok := value.(string); ok { + // This is a tagged name, e.g., "client_name#ja-Jpan-JP" + // Split the key at the first '#' to get the language tag. + parts := strings.SplitN(key, "#", 2) + if len(parts) == 2 { + langTag := parts[1] + c.ClientName[langTag] = name + } + } + case key == "client_uri": + if uri, ok := value.(string); ok { + // This is the default, non-tagged name. + c.ClientURI["default"] = uri + } + case strings.HasPrefix(key, "client_uri#"): + if uri, ok := value.(string); ok { + // This is a tagged name, e.g., "client_uri#ja-Jpan-JP" + // Split the key at the first '#' to get the language tag. + parts := strings.SplitN(key, "#", 2) + if len(parts) == 2 { + langTag := parts[1] + c.ClientURI[langTag] = uri + } + } + case key == "logo_uri": + if logo, ok := value.(string); ok { + // This is the default, non-tagged name. + c.LogoURI["default"] = logo + } + case strings.HasPrefix(key, "logo_uri#"): + if logo, ok := value.(string); ok { + // This is a tagged name, e.g., "logo_uri#ja-Jpan-JP" + // Split the key at the first '#' to get the language tag. + parts := strings.SplitN(key, "#", 2) + if len(parts) == 2 { + langTag := parts[1] + c.LogoURI[langTag] = logo + } + } + case key == "scope": + if v, ok := value.(string); ok { + c.Scope = v + } + case key == "contacts": + if cts, ok := value.([]interface{}); ok { + for _, ct := range cts { + if ctStr, ok := ct.(string); ok { + c.Contacts = append(c.Contacts, ctStr) + } + } + } + case key == "tos_uri": + if uri, ok := value.(string); ok { + // This is the default, non-tagged name. + c.TOSURI["default"] = uri + } + case strings.HasPrefix(key, "tos_uri#"): + if uri, ok := value.(string); ok { + // This is a tagged name, e.g., "tos_uri#ja-Jpan-JP" + // Split the key at the first '#' to get the language tag. + parts := strings.SplitN(key, "#", 2) + if len(parts) == 2 { + langTag := parts[1] + c.TOSURI[langTag] = uri + } + } + case key == "policy_uri": + if uri, ok := value.(string); ok { + // This is the default, non-tagged name. + c.PolicyURI["default"] = uri + } + case strings.HasPrefix(key, "policy_uri#"): + if uri, ok := value.(string); ok { + // This is a tagged name, e.g., "policy_uri#ja-Jpan-JP" + // Split the key at the first '#' to get the language tag. + parts := strings.SplitN(key, "#", 2) + if len(parts) == 2 { + langTag := parts[1] + c.PolicyURI[langTag] = uri + } + } + case key == "jwks_uri": + if v, ok := value.(string); ok { + c.JWKSURI = v + } + case key == "jwks": + if v, ok := value.(jose.JSONWebKeySet); ok { + c.JWKS = v + } + case key == "software_id": + if v, ok := value.(string); ok { + c.SoftwareID = v + } + case key == "software_version": + if v, ok := value.(string); ok { + c.SoftwareVersion = v + } + default: + // If the key didn't match any of the above, it's an extra parameter. + c.ExtraParameters[key] = value + } + } + + return nil } // ClientReadRequest implements diff --git a/pkg/oidc/dynamic_client_registration_test.go b/pkg/oidc/dynamic_client_registration_test.go index 7a011c25..0f4db95f 100644 --- a/pkg/oidc/dynamic_client_registration_test.go +++ b/pkg/oidc/dynamic_client_registration_test.go @@ -1,13 +1,80 @@ package oidc import ( + "crypto/rsa" + "encoding/base64" "encoding/json" + "github.com/go-jose/go-jose/v4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "math/big" "testing" ) +func compareRSAJSONWebKey( + t *testing.T, + wantEStr, wantNStr string, + gotJWKS jose.JSONWebKey, +) { + t.Helper() + + eBytes, err := base64.RawURLEncoding.DecodeString(wantEStr) + require.NoError(t, err) + nBytes, err := base64.RawURLEncoding.DecodeString(wantNStr) + require.NoError(t, err) + e := new(big.Int).SetBytes(eBytes).Int64() + n := new(big.Int).SetBytes(nBytes) + + pubKey, ok := gotJWKS.Key.(*rsa.PublicKey) + require.True(t, ok) + + assert.Equal(t, int(e), pubKey.E) + assert.Equal(t, n, pubKey.N) +} + func TestClientRegistrationRequest(t *testing.T) { + t.Run("test grant types", func(t *testing.T) { + marshalled := []byte(` +{ + "grant_types": [ + "authorization_code", + "refresh_token", + "client_credentials", + "urn:ietf:params:oauth:grant-type:jwt-bearer", + "urn:ietf:params:oauth:grant-type:token-exchange", + "implicit", + "urn:ietf:params:oauth:grant-type:device_code", + "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + ] +} +`) + var req ClientRegistrationRequest + err := json.Unmarshal(marshalled, &req) + require.NoError(t, err) + assert.Len(t, req.GrantTypes, 8) + assert.Contains(t, req.GrantTypes, GrantTypeCode) + assert.Contains(t, req.GrantTypes, GrantTypeRefreshToken) + assert.Contains(t, req.GrantTypes, GrantTypeClientCredentials) + assert.Contains(t, req.GrantTypes, GrantTypeBearer) + assert.Contains(t, req.GrantTypes, GrantTypeTokenExchange) + assert.Contains(t, req.GrantTypes, GrantTypeImplicit) + assert.Contains(t, req.GrantTypes, GrantTypeDeviceCode) + assert.Contains(t, req.GrantTypes, GrantType(ClientAssertionTypeJWTAssertion)) + }) + t.Run("test response types", func(t *testing.T) { + marshalled := []byte(` +{ + "response_types": ["code", "id_token token", "id_token"] +} +`) + var req ClientRegistrationRequest + err := json.Unmarshal(marshalled, &req) + require.NoError(t, err) + assert.Len(t, req.ResponseTypes, 3) + assert.Contains(t, req.ResponseTypes, ResponseTypeCode) + assert.Contains(t, req.ResponseTypes, ResponseTypeIDToken) + assert.Contains(t, req.ResponseTypes, ResponseTypeIDTokenOnly) + }) // example from https://www.rfc-editor.org/rfc/rfc7591#page-17 t.Run("unmarshal Client Registration Request example", func(t *testing.T) { marshalled := []byte(` @@ -28,6 +95,19 @@ func TestClientRegistrationRequest(t *testing.T) { err := json.Unmarshal(marshalled, &req) require.NoError(t, err) assert.Len(t, req.RedirectURIs, 2) + assert.Contains(t, req.RedirectURIs, "https://client.example.org/callback") + assert.Contains(t, req.RedirectURIs, "https://client.example.org/callback2") + assert.Len(t, req.ClientName, 2) + assert.Equal(t, "My Example Client", req.ClientName["default"]) + assert.Equal(t, "\u30AF\u30E9\u30A4\u30A2\u30F3\u30C8\u540D", req.ClientName["ja-Jpan-JP"]) + assert.Equal(t, AuthMethodBasic, req.TokenEndpointAuthMethod) + assert.Len(t, req.LogoURI, 1) + assert.Equal(t, "https://client.example.org/logo.png", req.LogoURI["default"]) + assert.Equal(t, "https://client.example.org/my_public_keys.jwks", req.JWKSURI) + assert.Contains(t, req.ExtraParameters, "example_extension_parameter") + assert.Len(t, req.ExtraParameters, 1) + assert.Contains(t, req.ExtraParameters, "example_extension_parameter") + assert.Equal(t, "example_value", req.ExtraParameters["example_extension_parameter"]) }) // example from https://www.rfc-editor.org/rfc/rfc7591#page-18 t.Run("unmarshal Client Registration Request example", func(t *testing.T) { @@ -54,6 +134,26 @@ func TestClientRegistrationRequest(t *testing.T) { var req ClientRegistrationRequest err := json.Unmarshal(marshalled, &req) require.NoError(t, err) + assert.Len(t, req.RedirectURIs, 2) + assert.Contains(t, req.RedirectURIs, "https://client.example.org/callback") + assert.Contains(t, req.RedirectURIs, "https://client.example.org/callback2") + assert.Len(t, req.ClientName, 2) + assert.Equal(t, "My Example Client", req.ClientName["default"]) + assert.Equal(t, "\u30AF\u30E9\u30A4\u30A2\u30F3\u30C8\u540D", req.ClientName["ja-Jpan-JP"]) + assert.Equal(t, AuthMethodBasic, req.TokenEndpointAuthMethod) + assert.Len(t, req.PolicyURI, 1) + assert.Equal(t, "https://client.example.org/policy.html", req.PolicyURI["default"]) + assert.Len(t, req.JWKS.Keys, 1) + compareRSAJSONWebKey( + t, + "AQAB", + "nj3YJwsLUFl9BmpAbkOswCNVx17Eh9wMO-_AReZwBqfaWFcfGHrZXsIV2VMCNVNU8Tpb4obUaSXcRcQ-VMsfQPJm9IzgtRdAY8NN8Xb7PEcYyklBjvTtuPbpzIaqyiUepzUXNDFuAOOkrIol3WmflPUUgMKULBN0EUd1fpOD70pRM0rlp_gg_WNUKoW1V-3keYUJoXH9NztEDm_D2MQXj9eGOJJ8yPgGL8PAZMLe2R7jb9TxOCPDED7tY_TU4nFPlxptw59A42mldEmViXsKQt60s1SLboazxFKveqXC_jpLUt22OC6GUG63p-REw-ZOr3r845z50wMuzifQrMI9bQ", + req.JWKS.Keys[0], + ) + assert.Len(t, req.ExtraParameters, 1) + assert.Contains(t, req.ExtraParameters, "example_extension_parameter") + assert.Equal(t, "example_value", req.ExtraParameters["example_extension_parameter"]) + }) // from https://www.rfc-editor.org/rfc/rfc7591#page-19 t.Run("unmarshal Client Registration Request example", func(t *testing.T) { @@ -71,11 +171,23 @@ func TestClientRegistrationRequest(t *testing.T) { var req ClientRegistrationRequest err := json.Unmarshal(marshalled, &req) require.NoError(t, err) + assert.Len(t, req.RedirectURIs, 2) + assert.Contains(t, req.RedirectURIs, "https://client.example.org/callback") + assert.Contains(t, req.RedirectURIs, "https://client.example.org/callback2") + assert.Equal( + t, + "eyJhbGciOiJSUzI1NiJ9.eyJzb2Z0d2FyZV9pZCI6IjROUkIxLTBYWkFCWkk5RTYtNVNNM1IiLCJjbGllbnRfbmFtZSI6IkV4YW1wbGUgU3RhdGVtZW50LWJhc2VkIENsaWVudCIsImNsaWVudF91cmkiOiJodHRwczovL2NsaWVudC5leGFtcGxlLm5ldC8ifQ.GHfL4QNIrQwL18BSRdE595T9jbzqa06R9BT8w409x9oIcKaZo_mt15riEXHazdISUvDIZhtiyNrSHQ8K4TvqWxH6uJgcmoodZdPwmWRIEYbQDLqPNxREtYn05X3AR7ia4FRjQ2ojZjk5fJqJdQ-JcfxyhK-P8BAWBd6I2LLA77IG32xtbhxYfHX7VhuU5ProJO8uvu3Ayv4XRhLZJY4yKfmyjiiKiPNe-Ia4SMy_d_QSWxskU5XIQl5Sa2YRPMbDRXttm2TfnZM1xx70DoYi8g6czz-CPGRi4SW_S2RKHIJfIjoI3zTJ0Y2oe0_EJAiXbL6OyF9S5tKxDXV8JIndSA", + req.SoftwareStatement, + ) + assert.Equal(t, "read write", req.Scope) + assert.Len(t, req.ExtraParameters, 1) + assert.Contains(t, req.ExtraParameters, "example_extension_parameter") + assert.Equal(t, "example_value", req.ExtraParameters["example_extension_parameter"]) }) } func TestClientUpdateRequest(t *testing.T) { - // from https://www.rfc-editor.org/rfc/rfc7592.html#page-7 + // from https://www.rfc-editor.org/rfc/rfc7592.html#page-8 t.Run("unmarshal Client Update Request example", func(t *testing.T) { marshalled := []byte(` { @@ -97,6 +209,19 @@ func TestClientUpdateRequest(t *testing.T) { var req ClientUpdateRequest err := json.Unmarshal(marshalled, &req) require.NoError(t, err) + assert.Equal(t, "s6BhdRkqt3", req.ClientID) + assert.Equal(t, "cf136dc3c1fc93f31185e5885805d", req.ClientSecret) + assert.Len(t, req.RedirectURIs, 2) + assert.Contains(t, req.RedirectURIs, "https://client.example.org/callback") + assert.Contains(t, req.RedirectURIs, "https://client.example.org/alt") + assert.Len(t, req.GrantTypes, 2) + assert.Contains(t, req.GrantTypes, GrantTypeCode) + assert.Contains(t, req.GrantTypes, GrantTypeRefreshToken) + assert.Equal(t, "https://client.example.org/my_public_keys.jwks", req.JWKSURI) + assert.Equal(t, "My New Example", req.ClientName["default"]) + assert.Equal(t, "Mon Nouvel Exemple", req.ClientName["fr"]) + assert.Equal(t, "https://client.example.org/newlogo.png", req.LogoURI["default"]) + assert.Equal(t, "https://client.example.org/fr/newlogo.png", req.LogoURI["fr"]) }) } @@ -125,15 +250,85 @@ func TestClientInformationResponse(t *testing.T) { var req ClientInformationResponse err := json.Unmarshal(marshalled, &req) require.NoError(t, err) + assert.Equal(t, "s6BhdRkqt3", req.ClientID) + assert.Equal(t, "cf136dc3c1fc93f31185e5885805d", req.ClientSecret) + assert.Equal(t, int64(2893256800), req.ClientIDIssuedAt) + assert.Equal(t, int64(2893276800), req.ClientSecretExpiresAt) + assert.Len(t, req.RedirectURIs, 2) + assert.Contains(t, req.RedirectURIs, "https://client.example.org/callback") + assert.Contains(t, req.RedirectURIs, "https://client.example.org/callback2") + assert.Len(t, req.GrantTypes, 2) + assert.Contains(t, req.GrantTypes, GrantTypeCode) + assert.Contains(t, req.GrantTypes, GrantTypeRefreshToken) + assert.Len(t, req.ClientName, 2) + assert.Equal(t, "My Example Client", req.ClientName["default"]) + assert.Equal(t, "\u30AF\u30E9\u30A4\u30A2\u30F3\u30C8\u540D", req.ClientName["ja-Jpan-JP"]) + assert.Equal(t, AuthMethodBasic, req.TokenEndpointAuthMethod) + assert.Len(t, req.LogoURI, 1) + assert.Equal(t, "https://client.example.org/logo.png", req.LogoURI["default"]) + assert.Equal(t, "https://client.example.org/my_public_keys.jwks", req.JWKSURI) + assert.Len(t, req.ExtraParameters, 1) + assert.Contains(t, req.ExtraParameters, "example_extension_parameter") + assert.Equal(t, "example_value", req.ExtraParameters["example_extension_parameter"]) + }) + // example from https://www.rfc-editor.org/rfc/rfc7591#page-21 + t.Run("unmarshal example, then marshal, unmarshal again", func(t *testing.T) { + marshalled1 := []byte(` +{ + "client_id": "s6BhdRkqt3", + "client_secret": "cf136dc3c1fc93f31185e5885805d", + "client_id_issued_at": 2893256800, + "client_secret_expires_at": 2893276800, + "redirect_uris": [ + "https://client.example.org/callback", + "https://client.example.org/callback2" + ], + "grant_types": ["authorization_code", "refresh_token"], + "client_name": "My Example Client", + "client_name#ja-Jpan-JP": "\u30AF\u30E9\u30A4\u30A2\u30F3\u30C8\u540D", + "token_endpoint_auth_method": "client_secret_basic", + "logo_uri": "https://client.example.org/logo.png", + "jwks_uri": "https://client.example.org/my_public_keys.jwks", + "example_extension_parameter": "example_value" +} +`) + var req1 ClientInformationResponse + require.NoError(t, json.Unmarshal(marshalled1, &req1)) + + marshalled2, err2 := json.Marshal(req1) + require.NoError(t, err2) + + var req3 ClientInformationResponse + require.NoError(t, json.Unmarshal(marshalled2, &req3)) + + assert.Equal(t, "s6BhdRkqt3", req3.ClientID) + assert.Equal(t, "cf136dc3c1fc93f31185e5885805d", req3.ClientSecret) + assert.Equal(t, int64(2893256800), req3.ClientIDIssuedAt) + assert.Equal(t, int64(2893276800), req3.ClientSecretExpiresAt) + assert.Len(t, req3.RedirectURIs, 2) + assert.Contains(t, req3.RedirectURIs, "https://client.example.org/callback") + assert.Contains(t, req3.RedirectURIs, "https://client.example.org/callback2") + assert.Len(t, req3.GrantTypes, 2) + assert.Contains(t, req3.GrantTypes, GrantTypeCode) + assert.Contains(t, req3.GrantTypes, GrantTypeRefreshToken) + assert.Len(t, req3.ClientName, 2) + assert.Equal(t, "My Example Client", req3.ClientName["default"]) + assert.Equal(t, "\u30AF\u30E9\u30A4\u30A2\u30F3\u30C8\u540D", req3.ClientName["ja-Jpan-JP"]) + assert.Equal(t, AuthMethodBasic, req3.TokenEndpointAuthMethod) + assert.Len(t, req3.LogoURI, 1) + assert.Equal(t, "https://client.example.org/logo.png", req3.LogoURI["default"]) + assert.Equal(t, "https://client.example.org/my_public_keys.jwks", req3.JWKSURI) + assert.Len(t, req3.ExtraParameters, 1) + assert.Contains(t, req3.ExtraParameters, "example_extension_parameter") + assert.Equal(t, "example_value", req3.ExtraParameters["example_extension_parameter"]) }) // example from https://www.rfc-editor.org/rfc/rfc7592.html#page-11 - t.Run("unmarshal example", func(t *testing.T) { - marshalled := []byte(` + t.Run("unmarshal example, then marshal, unmarshal again", func(t *testing.T) { + marshalled1 := []byte(` { "registration_access_token": "reg-23410913-abewfq.123483", - "registration_client_uri": - "https://server.example.com/register/s6BhdRkqt3", + "registration_client_uri": "https://server.example.com/register/s6BhdRkqt3", "client_id": "s6BhdRkqt3", "client_secret": "cf136dc3c1fc93f31185e5885805d", "client_id_issued_at": 2893256800, @@ -151,8 +346,34 @@ func TestClientInformationResponse(t *testing.T) { } `) var req ClientInformationResponse - err := json.Unmarshal(marshalled, &req) - require.NoError(t, err) + require.NoError(t, json.Unmarshal(marshalled1, &req)) + + var req1 ClientInformationResponse + require.NoError(t, json.Unmarshal(marshalled1, &req1)) + + marshalled2, err2 := json.Marshal(req1) + require.NoError(t, err2) + + var req3 ClientInformationResponse + require.NoError(t, json.Unmarshal(marshalled2, &req3)) + + assert.Equal(t, "reg-23410913-abewfq.123483", req3.RegistrationAccessToken) + assert.Equal(t, "https://server.example.com/register/s6BhdRkqt3", req3.RegistrationClientURI) + assert.Equal(t, "s6BhdRkqt3", req3.ClientID) + assert.Equal(t, int64(2893256800), req3.ClientIDIssuedAt) + assert.Equal(t, int64(2893276800), req3.ClientSecretExpiresAt) + assert.Len(t, req3.ClientName, 2) + assert.Equal(t, "My Example Client", req3.ClientName["default"]) + assert.Equal(t, "\u30AF\u30E9\u30A4\u30A2\u30F3\u30C8\u540D", req3.ClientName["ja-Jpan-JP"]) + assert.Len(t, req3.RedirectURIs, 2) + assert.Contains(t, req3.RedirectURIs, "https://client.example.org/callback") + assert.Contains(t, req3.RedirectURIs, "https://client.example.org/callback2") + assert.Len(t, req3.GrantTypes, 2) + assert.Contains(t, req3.GrantTypes, GrantTypeCode) + assert.Contains(t, req3.GrantTypes, GrantTypeRefreshToken) + assert.Len(t, req3.LogoURI, 1) + assert.Equal(t, "https://client.example.org/logo.png", req3.LogoURI["default"]) + assert.Equal(t, "https://client.example.org/my_public_keys.jwks", req3.JWKSURI) }) } diff --git a/pkg/oidc/token_request.go b/pkg/oidc/token_request.go index 1de18901..b0501d89 100644 --- a/pkg/oidc/token_request.go +++ b/pkg/oidc/token_request.go @@ -42,6 +42,17 @@ var AllGrantTypes = []GrantType{ GrantTypeDeviceCode, ClientAssertionTypeJWTAssertion, } +var GrantTypeMap = map[string]GrantType{ + string(GrantTypeCode): GrantTypeCode, + string(GrantTypeRefreshToken): GrantTypeRefreshToken, + string(GrantTypeClientCredentials): GrantTypeClientCredentials, + string(GrantTypeBearer): GrantTypeBearer, + string(GrantTypeTokenExchange): GrantTypeTokenExchange, + string(GrantTypeImplicit): GrantTypeImplicit, + string(GrantTypeDeviceCode): GrantTypeDeviceCode, + string(ClientAssertionTypeJWTAssertion): ClientAssertionTypeJWTAssertion, +} + type GrantType string const ( From fcd92dca7c5e1f8786874c8f6d02800c76e52b7c Mon Sep 17 00:00:00 2001 From: mqf20 Date: Fri, 1 Aug 2025 20:31:47 +0800 Subject: [PATCH 16/44] WIP improved request/response structs Signed-off-by: mqf20 --- pkg/oidc/dynamic_client_registration.go | 1387 ++++++++++++------ pkg/oidc/dynamic_client_registration_test.go | 336 +++-- 2 files changed, 1176 insertions(+), 547 deletions(-) diff --git a/pkg/oidc/dynamic_client_registration.go b/pkg/oidc/dynamic_client_registration.go index 7a11e233..d71ec4c2 100644 --- a/pkg/oidc/dynamic_client_registration.go +++ b/pkg/oidc/dynamic_client_registration.go @@ -7,37 +7,492 @@ import ( "strings" ) -// ClientRegistrationRequest implements -// https://www.rfc-editor.org/rfc/rfc7591#section-3.1, -// 3.1 Client Registration Request. +// ClientMetadata implements https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata, +// https://www.rfc-editor.org/rfc/rfc7591#section-2 and +// https://openid.net/specs/openid-connect-rpinitiated-1_0.html#ClientMetadata. // -// Can also be used for https://www.rfc-editor.org/rfc/rfc7592.html#section-2.1 -// 2.2 Client Update Request. +// The Client Metadata values are used in two ways: // -// TODO: handle BCP 47 -type ClientRegistrationRequest struct { - RedirectURIs []string `json:"redirect_uris"` // Array of redirection URI strings for use in redirect-based flows such as the authorization code and implicit flows. - TokenEndpointAuthMethod AuthMethod `json:"token_endpoint_auth_method"` // String indicator of the requested authentication method for the token endpoint. - GrantTypes []GrantType `json:"grant_types"` // Array of OAuth 2.0 grant type strings that the client can use at the token endpoint. - ResponseTypes []ResponseType `json:"response_types"` // Array of the OAuth 2.0 response type strings that the client can use at the authorization endpoint. - ClientName map[string]string `json:"client_name"` // Human-readable string name of the client to be presented to the end-user during authorization. (BCP 47) - ClientURI map[string]string `json:"client_uri"` // URL string of a web page providing information about the client. (BCP 47) - LogoURI map[string]string `json:"logo_uri"` // URL string that references a logo for the client. (BCP 47) - Scope string `json:"scope"` // String containing a space-separated list of scope values (as described in Section 3.3 of OAuth 2.0 [RFC6749]) that the client can use when requesting access tokens. - Contacts []string `json:"contacts"` // Array of strings representing ways to contact people responsible for this client, typically email addresses. - TOSURI map[string]string `json:"tos_uri"` // URL string that points to a human-readable terms of service document for the client that describes a contractual relationship between the end-user and the client that the end-user accepts when authorizing the client. (BCP 47) - PolicyURI map[string]string `json:"policy_uri"` // URL string that points to a human-readable privacy policy document that describes how the deployment organization collects, uses, retains, and discloses personal data. (BCP 47) - JWKSURI string `json:"jwks_uri"` // URL string referencing the client's JSON Web Key (JWK) Set [RFC7517] document, which contains the client's public keys. - JWKS jose.JSONWebKeySet `json:"jwks"` // Client's JSON Web Key Set [RFC7517] document value, which contains the client's public keys. - SoftwareID string `json:"software_id"` // A unique identifier string (e.g., a Universally Unique Identifier (UUID)) assigned by the client developer or software publisher used by registration endpoints to identify the client software to be dynamically registered. - SoftwareVersion string `json:"software_version"` // A version identifier string for the client software identified by "software_id". - SoftwareStatement string `json:"software_statement"` // A software statement containing client metadata values about the client software as claims. +// - as input values to registration requests (ClientRegistrationRequest), and +// - as output values in registration responses and read responses (ClientInformationResponse). +type ClientMetadata struct { + // Original fields suggested by RFC7591 (https://www.rfc-editor.org/rfc/rfc7591#section-2) + + // RedirectURIs is an array of redirection URI strings for use in redirect-based flows + // such as the authorization code and implicit flows. + // As required by [Section 2] of OAuth 2.0 [RFC6749], clients using flows with + // redirection MUST register their redirection URI values. + // Authorization servers that support dynamic registration for + // redirect-based flows MUST implement support for this metadata + // value. + // + // [Section 2]: https://www.rfc-editor.org/rfc/rfc7591#section-2 + // [RFC6749]: https://www.rfc-editor.org/rfc/rfc6749 + RedirectURIs []string `json:"redirect_uris"` + + // TokenEndpointAuthMethod is a string indicator of the requested authentication method for the + // token endpoint. Values defined by this specification are: + // + // - "none": The client is a public client as defined in OAuth 2.0, + // [Section 2.1], and does not have a client secret. + // + // - "client_secret_post": The client uses the HTTP POST parameters + // as defined in OAuth 2.0, [Section 2.3.1]. + // + // - "client_secret_basic": The client uses HTTP Basic as defined in + // OAuth 2.0, [Section 2.3.1]. + // + // Additional values can be defined via the IANA "OAuth Token + // Endpoint Authentication Methods" registry established in + // Section 4.2. Absolute URIs can also be used as values for this + // parameter without being registered. If unspecified or omitted, + // the default is "client_secret_basic", denoting the HTTP Basic + // authentication scheme as specified in [Section 2.3.1] of OAuth 2.0. + // + // [Section 2.1]: https://www.rfc-editor.org/rfc/rfc7591#section-2.1 + // [Section 2.3.1]: https://www.rfc-editor.org/rfc/rfc7591#section-2.3.1 + TokenEndpointAuthMethod AuthMethod `json:"token_endpoint_auth_method"` + + // GrantTypes is an array of OAuth 2.0 grant type strings that the client can use at + // the token endpoint. These grant types are defined as follows: + // + // - "authorization_code": The authorization code grant type defined + // in OAuth 2.0, [Section 4.1]. + // + // - "implicit": The implicit grant type defined in OAuth 2.0, + // [Section 4.2]. + // + // - "password": The resource owner password credentials grant type + // defined in OAuth 2.0, [Section 4.3]. + // + // - "client_credentials": The client credentials grant type defined + // in OAuth 2.0, [Section 4.4]. + // + // - "refresh_token": The refresh token grant type defined in OAuth + // 2.0, [Section 6]. + // + // - "urn:ietf:params:oauth:grant-type:jwt-bearer": The JWT Bearer + // Token Grant Type defined in OAuth JWT Bearer Token Profiles + // [RFC7523]. + // + // * "urn:ietf:params:oauth:grant-type:saml2-bearer": The SAML 2.0 + // Bearer Assertion Grant defined in OAuth SAML 2 Bearer Token + // Profiles [RFC7522]. + // + // If the token endpoint is used in the grant type, the value of this + // parameter MUST be the same as the value of the "grant_type" + // parameter passed to the token endpoint defined in the grant type + // definition. Authorization servers MAY allow for other values as + // defined in the grant type extension process described in OAuth + // 2.0, [Section 4.5]. If omitted, the default behavior is that the + // client will use only the "authorization_code" Grant Type. + // + // [Section 4.1]: https://www.rfc-editor.org/rfc/rfc7591#section-4.1 + // [Section 4.2]: https://www.rfc-editor.org/rfc/rfc7591#section-4.2 + // [Section 4.3]: https://www.rfc-editor.org/rfc/rfc7591#section-4.3 + // [Section 4.4]: https://www.rfc-editor.org/rfc/rfc7591#section-4.4 + // [Section 4.5]: https://www.rfc-editor.org/rfc/rfc7591#section-4.5 + // [Section 6]: https://www.rfc-editor.org/rfc/rfc7591#section-6 + // [RFC7523]: https://www.rfc-editor.org/rfc/rfc7523 + // [RFC7522]: https://www.rfc-editor.org/rfc/rfc7522 + GrantTypes []GrantType `json:"grant_types"` + + // ResponseTypes is an array of the OAuth 2.0 response type strings that the client can + // use at the authorization endpoint. These response types are + // defined as follows: + // + // - "code": The authorization code response type defined in OAuth + // 2.0, [Section 4.1]. + // + // - "token": The implicit response type defined in OAuth 2.0, + // [Section 4.2]. + // + // If the authorization endpoint is used by the grant type, the value + // of this parameter MUST be the same as the value of the + // "response_type" parameter passed to the authorization endpoint + // defined in the grant type definition. Authorization servers MAY + // allow for other values as defined in the grant type extension + // process is described in OAuth 2.0, [Section 4.5]. If omitted, the + // default is that the client will use only the "code" response type. + // + // [Section 4.1]: https://www.rfc-editor.org/rfc/rfc7591#section-4.1 + // [Section 4.2]: https://www.rfc-editor.org/rfc/rfc7591#section-4.2 + // [Section 4.5]: https://www.rfc-editor.org/rfc/rfc7591#section-4.5 + ResponseTypes []ResponseType `json:"response_types"` + + // ClientName is a human-readable string name of the client to be presented to the + // end-user during authorization. If omitted, the authorization + // server MAY display the raw "client_id" value to the end-user + // instead. It is RECOMMENDED that clients always send this field. + // The value of this field MAY be internationalized, as described in + // [Section 2.2]. + // + // [Section 2.2]: https://www.rfc-editor.org/rfc/rfc7591#section-2.2 + ClientName map[string]string `json:"client_name"` + + // ClientURI is a URL string of a web page providing information about the client. + // If present, the server SHOULD display this URL to the end-user in + // a clickable fashion. It is RECOMMENDED that clients always send + // this field. The value of this field MUST point to a valid web + // page. The value of this field MAY be internationalized, as + // described in [Section 2.2]. + // + // [Section 2.2]: https://www.rfc-editor.org/rfc/rfc7591#section-2.2 + ClientURI map[string]string `json:"client_uri"` + + // LogoURI is a URL string that references a logo for the client. If present, the + // server SHOULD display this image to the end-user during approval. + // The value of this field MUST point to a valid image file. The + // value of this field MAY be internationalized, as described in + // [Section 2.2]. + // + // [Section 2.2]: https://www.rfc-editor.org/rfc/rfc7591#section-2.2 + LogoURI map[string]string `json:"logo_uri"` + + // Scope is a string containing a space-separated list of scope values (as + // described in [Section 3.3] of OAuth 2.0 [RFC6749]) that the client + // can use when requesting access tokens. The semantics of values in + // this list are service specific. If omitted, an authorization + // server MAY register a client with a default set of scopes. + // + // [Section 3.3]: https://www.rfc-editor.org/rfc/rfc7591#section-3.3 + // [RFC6749]: https://www.rfc-editor.org/rfc/rfc6749 + Scope string `json:"scope"` + + // Contacts is an array of strings representing ways to contact people responsible + // for this client, typically email addresses. The authorization + // server MAY make these contact addresses available to end-users for + // support requests for the client. See [Section 6] for information on + // Privacy Considerations. + // + // [Section 6]: https://www.rfc-editor.org/rfc/rfc7591#section-6 + Contacts []string `json:"contacts"` + + // TOSURI is a URL string that points to a human-readable terms of service + // document for the client that describes a contractual relationship + // between the end-user and the client that the end-user accepts when + // authorizing the client. The authorization server SHOULD display + // this URL to the end-user if it is provided. The value of this + // field MUST point to a valid web page. The value of this field MAY + // be internationalized, as described in [Section 2.2]. + // + // [Section 2.2]: https://www.rfc-editor.org/rfc/rfc7591#section-2.2 + TOSURI map[string]string `json:"tos_uri"` + + // PolicyURI is a URL string that points to a human-readable privacy policy document + // that describes how the deployment organization collects, uses, + // retains, and discloses personal data. The authorization server + // SHOULD display this URL to the end-user if it is provided. The + // value of this field MUST point to a valid web page. The value of + // this field MAY be internationalized, as described in [Section 2.2]. + // + // [Section 2.2]: https://www.rfc-editor.org/rfc/rfc7591#section-2.2 + PolicyURI map[string]string `json:"policy_uri"` + + // JWKSURI is a URL string referencing the client's JSON Web Key (JWK) Set + // [RFC7517] document, which contains the client's public keys. The + // value of this field MUST point to a valid JWK Set document. These + // keys can be used by higher-level protocols that use signing or + // encryption. For instance, these keys might be used by some + // applications for validating signed requests made to the token + // endpoint when using JWTs for client authentication [RFC7523]. Use + // of this parameter is preferred over the "jwks" parameter, as it + // allows for easier key rotation. The "jwks_uri" and "jwks" + // parameters MUST NOT both be present in the same request or + // response. + // + // [RFC7517]: https://www.rfc-editor.org/rfc/rfc7517 + // [RFC7523]: https://www.rfc-editor.org/rfc/rfc7523 + JWKSURI string `json:"jwks_uri"` + + // JWKS is the Client's JSON Web Key Set [RFC7517] document value, which contains + // the client's public keys. The value of this field MUST be a JSON + // object containing a valid JWK Set. These keys can be used by + // higher-level protocols that use signing or encryption. This + // parameter is intended to be used by clients that cannot use the + // "jwks_uri" parameter, such as native clients that cannot host + // public URLs. The "jwks_uri" and "jwks" parameters MUST NOT both + // be present in the same request or response. + // + // [RFC7517]: https://www.rfc-editor.org/rfc/rfc7517 + JWKS jose.JSONWebKeySet `json:"jwks"` + + // SoftwareID is a unique identifier string (e.g., a Universally Unique Identifier + // (UUID)) assigned by the client developer or software publisher + // used by registration endpoints to identify the client software to + // be dynamically registered. Unlike "client_id", which is issued by + // the authorization server and SHOULD vary between instances, the + // "software_id" SHOULD remain the same for all instances of the + // client software. The "software_id" SHOULD remain the same across + // multiple updates or versions of the same piece of software. The + // value of this field is not intended to be human readable and is + // usually opaque to the client and authorization server. + SoftwareID string `json:"software_id"` + + // SoftwareVersion is a version identifier string for the client software identified by + // "software_id". The value of the "software_version" SHOULD change + // on any update to the client software identified by the same + // "software_id". The value of this field is intended to be compared + // using string equality matching and no other comparison semantics + // are defined by this specification. The value of this field is + // outside the scope of this specification, but it is not intended to + // be human readable and is usually opaque to the client and + // authorization server. The definition of what constitutes an + // update to client software that would trigger a change to this + // value is specific to the software itself and is outside the scope + // of this specification. + SoftwareVersion string `json:"software_version"` + + // Additional fields suggested by OpenID Connect Dynamic Client Registration 1.0 + // (https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata) + + // ApplicationType is a kind of the application. + // + // The default, if omitted, is op.ApplicationTypeWeb. + // + // The defined values are op.ApplicationTypeNative or op.ApplicationTypeWeb. + // + // Web Clients using the OAuth Implicit Grant Type MUST only register URLs using the https scheme as redirect_uris; + // they MUST NOT use localhost as the hostname. + // + // Native Clients MUST only register redirect_uris using custom URI schemes or loopback URLs using the http scheme; + // loopback URLs use localhost or the IP loopback literals 127.0.0.1 or [::1] as the hostname. + // + // Authorization Servers MAY place additional constraints on Native Clients. + // + // Authorization Servers MAY reject Redirection URI values using the http scheme, other than the loopback case for + // Native Clients. + // + // The Authorization Server MUST verify that all the registered redirect_uris conform to these constraints. + // This prevents sharing a Client ID across different types of Clients. + // + // OPTIONAL. + // + // N.B.: Cannot use op.ApplicationType because of cyclic imports. + ApplicationType string `json:"application_type,omitempty"` + + // SectorIdentifierURI is a URL using the https scheme to be used in calculating + // Pseudonymous Identifiers by the OP. + // The URL references a file with a single JSON array of redirect_uri values. Please see [Section 5]. + // Providers that use pairwise sub (subject) values SHOULD utilize the sector_identifier_uri value provided + // in the Subject Identifier calculation for pairwise identifiers. + // + // OPTIONAL. + // + // [Section 5]: https://openid.net/specs/openid-connect-registration-1_0.html#SectorIdentifierValidation + SectorIdentifierURI string `json:"sector_identifier_uri,omitempty"` + + // SubjectType is the subject_type requested for responses to this Client. + // The subject_types_supported discovery parameter contains a list of the supported subject_type values for the OP. + // Valid types include pairwise and public. + // + // OPTIONAL. + SubjectType string `json:"subject_type,omitempty"` + + // IDTokenSignedResponseAlg is a JWS alg algorithm [JWA] REQUIRED for signing the ID Token issued to this Client. + // The value none MUST NOT be used as the ID Token alg value unless the Client uses only Response Types that + // return no ID Token from the Authorization Endpoint (such as when only using the Authorization Code Flow). + //The default, if omitted, is RS256. + //The public key for validating the signature is provided by retrieving the JWK Set referenced by the + // jwks_uri element from [OpenID Connect Discovery 1.0] [OpenID.Discovery]. + // + // OPTIONAL. + // + // [JWA]: https://openid.net/specs/openid-connect-registration-1_0.html#JWA + // [OpenID Connect Discovery 1.0]: https://openid.net/specs/openid-connect-registration-1_0.html#OpenID.Discovery + IDTokenSignedResponseAlg string `json:"id_token_signed_response_alg,omitempty"` + + // IDTokenEncryptedResponseAlg is a JWE alg algorithm [JWA] REQUIRED for encrypting the ID Token issued to this + // Client. If this is requested, the response will be signed then encrypted, with the result being a Nested JWT, + // as defined in [JWT]. + // The default, if omitted, is that no encryption is performed. + // + // OPTIONAL. + // + // [JWA]: https://openid.net/specs/openid-connect-registration-1_0.html#JWA + // [JWT]: https://openid.net/specs/openid-connect-registration-1_0.html#JWT + IDTokenEncryptedResponseAlg string `json:"id_token_encrypted_response_alg,omitempty"` + + // IDTokenEncryptedResponseEnc is a JWE enc algorithm [JWA] REQUIRED for encrypting the ID Token issued to + // this Client. + // If id_token_encrypted_response_alg is specified, + // the default id_token_encrypted_response_enc value is A128CBC-HS256. + // When id_token_encrypted_response_enc is included, id_token_encrypted_response_alg MUST also be provided. + // + // OPTIONAL. + // + // [JWA]: https://openid.net/specs/openid-connect-registration-1_0.html#JWA + IDTokenEncryptedResponseEnc string `json:"id_token_encrypted_response_enc,omitempty"` + + // UserinfoSignedResponseAlg is a JWS alg algorithm [JWA] REQUIRED for signing UserInfo Responses. + // If this is specified, the response will be JWT [JWT] serialized, and signed using JWS. + // The default, if omitted, is for the UserInfo Response to return the Claims as a UTF-8 [RFC3629] + // encoded JSON object using the application/json content-type. + // + // OPTIONAL. + // + // [JWA]: https://openid.net/specs/openid-connect-registration-1_0.html#JWA + // [JWT]: https://openid.net/specs/openid-connect-registration-1_0.html#JWT + // [RFC3629]: https://openid.net/specs/openid-connect-registration-1_0.html#RFC3629 + UserinfoSignedResponseAlg string `json:"userinfo_signed_response_alg,omitempty"` + + // UserinfoEncryptedResponseAlg is a JWE [JWE] alg algorithm [JWA] REQUIRED for encrypting UserInfo Responses. + // If both signing and encryption are requested, the response will be signed then encrypted, + // with the result being a Nested JWT, as defined in [JWT]. + // The default, if omitted, is that no encryption is performed. + // + // OPTIONAL. + // + // [JWE]: https://openid.net/specs/openid-connect-registration-1_0.html#JWE + // [JWA]: https://openid.net/specs/openid-connect-registration-1_0.html#JWA + // [JWT]: https://openid.net/specs/openid-connect-registration-1_0.html#JWT + UserinfoEncryptedResponseAlg string `json:"userinfo_encrypted_response_alg,omitempty"` + + // UserinfoEncryptedResponseEnc is a JWE enc algorithm [JWA] REQUIRED for encrypting UserInfo Responses. + // If userinfo_encrypted_response_alg is specified, + // the default userinfo_encrypted_response_enc value is A128CBC-HS256. + // When userinfo_encrypted_response_enc is included, userinfo_encrypted_response_alg MUST also be provided. + // + // OPTIONAL. + // + // [JWA]: https://openid.net/specs/openid-connect-registration-1_0.html#JWA + UserinfoEncryptedResponseEnc string `json:"userinfo_encrypted_response_enc,omitempty"` + + // RequestObjectSigningAlg is a JWS [JWS] alg algorithm [JWA] that MUST be used for signing Request Objects sent + // to the OP. + // All Request Objects from this Client MUST be rejected, if not signed with this algorithm. + // Request Objects are described in Section 6.1 of [OpenID Connect Core 1.0] [OpenID.Core]. + // This algorithm MUST be used both when the Request Object is passed by value (using the request parameter) + // and when it is passed by reference (using the request_uri parameter). + // Servers SHOULD support RS256. The value none MAY be used. + // The default, if omitted, is that any algorithm supported by the OP and the RP MAY be used. + // + // OPTIONAL. + // + // [JWS]: https://openid.net/specs/openid-connect-registration-1_0.html#JWS + // [JWA]: https://openid.net/specs/openid-connect-registration-1_0.html#JWA + // [OpenID Connect Core 1.0]: https://openid.net/specs/openid-connect-registration-1_0.html#OpenID.Core + RequestObjectSigningAlg string `json:"request_object_signing_alg,omitempty"` + + // RequestObjectEncryptionAlg is a JWE [JWE] alg algorithm [JWA] + // the RP is declaring that it may use for encrypting Request Objects sent to the OP. + // This parameter SHOULD be included when symmetric encryption will be used, + // since this signals to the OP that a client_secret value needs to be returned from + // which the symmetric key will be derived, that might not otherwise be returned. + // The RP MAY still use other supported encryption algorithms or send unencrypted Request Objects, + // even when this parameter is present. + // If both signing and encryption are requested, + // the Request Object will be signed then encrypted, + // with the result being a Nested JWT, as defined in [JWT]. + // The default, if omitted, is that the RP is not declaring whether it might encrypt any Request Objects. + // + // OPTIONAL. + // + // [JWE]: https://openid.net/specs/openid-connect-registration-1_0.html#JWE + // [JWA]: https://openid.net/specs/openid-connect-registration-1_0.html#JWA + // [JWT]: https://openid.net/specs/openid-connect-registration-1_0.html#JWT + RequestObjectEncryptionAlg string `json:"request_object_encryption_alg,omitempty"` + + // RequestObjectEncryptionEnc is a JWE enc algorithm [JWA] the RP is declaring that it may use for encrypting + // Request Objects sent to the OP. + // If request_object_encryption_alg is specified, the default request_object_encryption_enc value is A128CBC-HS256. + // When request_object_encryption_enc is included, request_object_encryption_alg MUST also be provided. + // + // OPTIONAL. + // + // [JWA]: https://openid.net/specs/openid-connect-registration-1_0.html#JWA + RequestObjectEncryptionEnc string `json:"request_object_encryption_enc,omitempty"` + + // TokenEndpointAuthSigningAlg is a JWS [JWS] alg algorithm [JWA] that MUST be used for signing the + // JWT [JWT] used to authenticate the Client at the Token Endpoint for the private_key_jwt + // and client_secret_jwt authentication methods. + // All Token Requests using these authentication methods from this Client MUST be rejected, + // if the JWT is not signed with this algorithm. + // Servers SHOULD support RS256. + // The value none MUST NOT be used. + // The default, if omitted, is that any algorithm supported by the OP and the RP MAY be used. + // + // OPTIONAL. + // + // [JWS]: https://openid.net/specs/openid-connect-registration-1_0.html#JWS + // [JWA]: https://openid.net/specs/openid-connect-registration-1_0.html#JWA + // [JWT]: https://openid.net/specs/openid-connect-registration-1_0.html#JWT + TokenEndpointAuthSigningAlg string `json:"token_endpoint_auth_signing_alg,omitempty"` + + // DefaultMaxAge is the Default Maximum Authentication Age. + // Specifies that the End-User MUST be actively authenticated + // if the End-User was authenticated longer ago than the specified number of seconds. + // The max_age request parameter overrides this default value. + // If omitted, no default Maximum Authentication Age is specified. + // + // OPTIONAL. + DefaultMaxAge int `json:"default_max_age,omitempty"` + + // RequireAuthTime is a boolean value specifying whether the auth_time Claim in the ID Token is REQUIRED. + // It is REQUIRED when the value is true. + // (If this is false, the auth_time Claim can still be dynamically requested as + // an individual Claim for the ID Token using the claims request parameter described in + // Section 5.5.1 of [OpenID Connect Core 1.0] [OpenID.Core].) + // If omitted, the default value is false. + // + // OPTIONAL. + // + // [OpenID Connect Core 1.0]: https://openid.net/specs/openid-connect-registration-1_0.html#OpenID.Core + RequireAuthTime bool `json:"require_auth_time,omitempty"` + + // DefaultACRValues are default requested Authentication Context Class Reference values. + // Array of strings that specifies the default acr values that the OP is being requested to use for + // processing requests from this Client, with the values appearing in order of preference. + // The Authentication Context Class satisfied by the authentication performed is returned as the + // acr Claim Value in the issued ID Token. + // The acr Claim is requested as a Voluntary Claim by this parameter. + // The acr_values_supported discovery element contains a list of the supported acr values supported by the OP. + // Values specified in the acr_values request parameter or + // an individual acr Claim request override these default values. + DefaultACRValues []string `json:"default_acr_values,omitempty"` + + // InitiateLoginURI is a URI using the https scheme that a third party can use to initiate a login by the RP, + // as specified in Section 4 of [OpenID Connect Core 1.0] [OpenID.Core]. + // The URI MUST accept requests via both GET and POST. + // The Client MUST understand the login_hint and iss parameters and SHOULD support the target_link_uri parameter. + // + // OPTIONAL. + // + // [OpenID Connect Core 1.0]: https://openid.net/specs/openid-connect-registration-1_0.html#OpenID.Core + InitiateLoginURI string `json:"initiate_login_uri,omitempty"` + + // RequestURIs is an array of request_uri values that are pre-registered by the RP for use at the OP. + // These URLs MUST use the https scheme unless the target Request Object is + // signed in a way that is verifiable by the OP. + // Servers MAY cache the contents of the files referenced by these URIs and not retrieve them at the time + // they are used in a request. + // OPs can require that request_uri values used be pre-registered with + // the require_request_uri_registration discovery parameter. + // If the contents of the request file could ever change, + // these URI values SHOULD include the base64url-encoded SHA-256 hash value of the file contents + // referenced by the URI as the value of the URI fragment. + // If the fragment value used for a URI changes, + // that signals the server that its cached value for that URI with the old fragment value is no longer valid. + RequestURIs []string `json:"request_uris,omitempty"` + + // Additional fields suggested by OpenID Connect RP-Initiated Logout 1.0 + // (https://openid.net/specs/openid-connect-rpinitiated-1_0.html#ClientMetadata) + + // PostLogoutRedirectURIs is an array of URLs supplied by the RP + // to which it MAY request that the End-User's User Agent be redirected using + // the post_logout_redirect_uri parameter after a logout has been performed. + // These URLs SHOULD use the https scheme and MAY contain port, path, and query parameter components; + // however, they MAY use the http scheme, provided that the Client Type is confidential, + // as defined in Section 2.1 of [OAuth 2.0] [RFC6749], and provided the OP allows the use of http RP URIs. + // + // [OAuth 2.0]: https://openid.net/specs/openid-connect-rpinitiated-1_0.html#RFC6749 + PostLogoutRedirectURIs []string `json:"post_logout_redirect_uris,omitempty"` // ExtraParameters holds other extension parameters. ExtraParameters map[string]interface{} } -func (c *ClientRegistrationRequest) UnmarshalJSON(data []byte) error { +func (c *ClientMetadata) UnmarshalJSON(data []byte) error { // Initialize maps to avoid nil pointer issues later. c.ClientName = make(map[string]string) c.ClientURI = make(map[string]string) @@ -193,9 +648,97 @@ func (c *ClientRegistrationRequest) UnmarshalJSON(data []byte) error { if v, ok := value.(string); ok { c.SoftwareVersion = v } - case key == "software_statement": + //case key == "software_statement": + // if v, ok := value.(string); ok { + // c.SoftwareStatement = v + // } + case key == "application_type": + if v, ok := value.(string); ok { + c.ApplicationType = v + } + case key == "sector_identifier_uri": + if v, ok := value.(string); ok { + c.SectorIdentifierURI = v + } + case key == "subject_type": + if v, ok := value.(string); ok { + c.SubjectType = v + } + case key == "id_token_signed_response_alg": + if v, ok := value.(string); ok { + c.IDTokenSignedResponseAlg = v + } + case key == "id_token_encrypted_response_alg": + if v, ok := value.(string); ok { + c.IDTokenEncryptedResponseAlg = v + } + case key == "id_token_encrypted_response_enc": + if v, ok := value.(string); ok { + c.IDTokenEncryptedResponseEnc = v + } + case key == "userinfo_signed_response_alg": + if v, ok := value.(string); ok { + c.UserinfoSignedResponseAlg = v + } + case key == "userinfo_encrypted_response_alg": + if v, ok := value.(string); ok { + c.UserinfoEncryptedResponseAlg = v + } + case key == "userinfo_encrypted_response_enc": + if v, ok := value.(string); ok { + c.UserinfoEncryptedResponseEnc = v + } + case key == "request_object_signing_alg": + if v, ok := value.(string); ok { + c.RequestObjectSigningAlg = v + } + case key == "request_object_encryption_alg": + if v, ok := value.(string); ok { + c.RequestObjectEncryptionAlg = v + } + case key == "request_object_encryption_enc": + if v, ok := value.(string); ok { + c.RequestObjectEncryptionEnc = v + } + case key == "token_endpoint_auth_signing_alg": + if v, ok := value.(string); ok { + c.TokenEndpointAuthSigningAlg = v + } + case key == "default_max_age": + if v, ok := value.(float64); ok { + c.DefaultMaxAge = int(v) + } + case key == "require_auth_time": + if v, ok := value.(bool); ok { + c.RequireAuthTime = v + } + case key == "default_acr_values": + if acrs, ok := value.([]interface{}); ok { + for _, acr := range acrs { + if acrStr, ok := acr.(string); ok { + c.DefaultACRValues = append(c.DefaultACRValues, acrStr) + } + } + } + case key == "initiate_login_uri": if v, ok := value.(string); ok { - c.SoftwareStatement = v + c.InitiateLoginURI = v + } + case key == "request_uris": + if uris, ok := value.([]interface{}); ok { + for _, uri := range uris { + if uriStr, ok := uri.(string); ok { + c.RequestURIs = append(c.RequestURIs, uriStr) + } + } + } + case key == "post_logout_redirect_uris": + if uris, ok := value.([]interface{}); ok { + for _, uri := range uris { + if uriStr, ok := uri.(string); ok { + c.PostLogoutRedirectURIs = append(c.PostLogoutRedirectURIs, uriStr) + } + } } default: // If the key didn't match any of the above, it's an extra parameter. @@ -206,66 +749,25 @@ func (c *ClientRegistrationRequest) UnmarshalJSON(data []byte) error { return nil } -// ClientInformationResponse implements -// https://www.rfc-editor.org/rfc/rfc7591#section-3.2.1, -// 3.2.1. Client Information Response and -// https://www.rfc-editor.org/rfc/rfc7592.html#section-3 -// 3. Client Information Response. -type ClientInformationResponse struct { - ClientID string `json:"client_id"` // OAuth 2.0 client identifier string. - ClientSecret string `json:"client_secret,omitempty"` // OAuth 2.0 client secret string. - ClientIDIssuedAt int64 `json:"client_id_issued_at,omitempty"` // Time at which the client identifier was issued. - ClientSecretExpiresAt int64 `json:"client_secret_expires_at,omitempty"` // Time at which the client secret will expire or 0 if it will not expire. - - // fields that are reused from ClientRegistrationRequest - RedirectURIs []string `json:"redirect_uris,omitempty"` // Array of redirection URI strings for use in redirect-based flows such as the authorization code and implicit flows. - TokenEndpointAuthMethod AuthMethod `json:"token_endpoint_auth_method,omitempty"` // String indicator of the requested authentication method for the token endpoint. - GrantTypes []GrantType `json:"grant_types,omitempty"` // Array of OAuth 2.0 grant type strings that the client can use at the token endpoint. - ResponseTypes []ResponseType `json:"response_types,omitempty"` // Array of the OAuth 2.0 response type strings that the client can use at the authorization endpoint. - ClientName map[string]string `json:"client_name,omitempty"` // Human-readable string name of the client to be presented to the end-user during authorization. (BCP 47) - ClientURI map[string]string `json:"client_uri,omitempty"` // URL string of a web page providing information about the client. (BCP 47) - LogoURI map[string]string `json:"logo_uri,omitempty"` // URL string that references a logo for the client. (BCP 47) - Scope string `json:"scope,omitempty"` // String containing a space-separated list of scope values (as described in Section 3.3 of OAuth 2.0 [RFC6749]) that the client can use when requesting access tokens. - Contacts []string `json:"contacts,omitempty"` // Array of strings representing ways to contact people responsible for this client, typically email addresses. - TOSURI map[string]string `json:"tos_uri,omitempty"` // URL string that points to a human-readable terms of service document for the client that describes a contractual relationship between the end-user and the client that the end-user accepts when authorizing the client. (BCP 47) - PolicyURI map[string]string `json:"policy_uri,omitempty"` // URL string that points to a human-readable privacy policy document that describes how the deployment organization collects, uses, retains, and discloses personal data. (BCP 47) - JWKSURI string `json:"jwks_uri,omitempty"` // URL string referencing the client's JSON Web Key (JWK) Set [RFC7517] document, which contains the client's public keys. - JWKS jose.JSONWebKeySet `json:"jwks,omitempty"` // Client's JSON Web Key Set [RFC7517] document value, which contains the client's public keys. - SoftwareID string `json:"software_id,omitempty"` // A unique identifier string (e.g., a Universally Unique Identifier (UUID)) assigned by the client developer or software publisher used by registration endpoints to identify the client software to be dynamically registered. - SoftwareVersion string `json:"software_version,omitempty"` // A version identifier string for the client software identified by "software_id". - RegistrationAccessToken string `json:"registration_access_token,omitempty"` - RegistrationClientURI string `json:"registration_client_uri,omitempty"` - - // ExtraParameters holds other extension parameters. - ExtraParameters map[string]interface{} -} - -func (c ClientInformationResponse) MarshalJSON() ([]byte, error) { +func (c ClientMetadata) MarshalJSON() ([]byte, error) { res := make(map[string]interface{}) - res["client_id"] = c.ClientID // always present - if c.ClientSecret != "" { - res["client_secret"] = c.ClientSecret - } - if c.ClientIDIssuedAt != 0 { - res["client_id_issued_at"] = c.ClientIDIssuedAt - } - if c.ClientSecretExpiresAt != 0 { - res["client_secret_expires_at"] = c.ClientSecretExpiresAt - } - if len(c.RedirectURIs) > 0 { res["redirect_uris"] = c.RedirectURIs } + if c.TokenEndpointAuthMethod != "" { res["token_endpoint_auth_method"] = c.TokenEndpointAuthMethod } + if len(c.GrantTypes) > 0 { res["grant_types"] = c.GrantTypes } + if len(c.ResponseTypes) > 0 { res["response_types"] = c.ResponseTypes } + if len(c.ClientName) > 0 { for lang, name := range c.ClientName { if lang == "default" { @@ -275,6 +777,7 @@ func (c ClientInformationResponse) MarshalJSON() ([]byte, error) { } } } + if len(c.ClientURI) > 0 { for lang, uri := range c.ClientURI { if lang == "default" { @@ -284,6 +787,7 @@ func (c ClientInformationResponse) MarshalJSON() ([]byte, error) { } } } + if len(c.LogoURI) > 0 { for lang, logo := range c.LogoURI { if lang == "default" { @@ -293,12 +797,15 @@ func (c ClientInformationResponse) MarshalJSON() ([]byte, error) { } } } + if c.Scope != "" { res["scope"] = c.Scope } + if len(c.Contacts) > 0 { res["contacts"] = c.Contacts } + if len(c.TOSURI) > 0 { for lang, uri := range c.TOSURI { if lang == "default" { @@ -308,6 +815,7 @@ func (c ClientInformationResponse) MarshalJSON() ([]byte, error) { } } } + if len(c.PolicyURI) > 0 { for lang, uri := range c.PolicyURI { if lang == "default" { @@ -317,23 +825,97 @@ func (c ClientInformationResponse) MarshalJSON() ([]byte, error) { } } } + if c.JWKSURI != "" { res["jwks_uri"] = c.JWKSURI } + if len(c.JWKS.Keys) > 0 { res["jwks"] = c.JWKS } + if c.SoftwareID != "" { res["software_id"] = c.SoftwareID } + if c.SoftwareVersion != "" { res["software_version"] = c.SoftwareVersion } - if c.RegistrationAccessToken != "" { - res["registration_access_token"] = c.RegistrationAccessToken + + if c.ApplicationType != "" { + res["application_type"] = c.ApplicationType } - if c.RegistrationClientURI != "" { - res["registration_client_uri"] = c.RegistrationClientURI + + if c.SectorIdentifierURI != "" { + res["sector_identifier_uri"] = c.SectorIdentifierURI + } + + if c.SubjectType != "" { + res["subject_type"] = c.SubjectType + } + + if c.IDTokenSignedResponseAlg != "" { + res["id_token_signed_response_alg"] = c.IDTokenSignedResponseAlg + } + + if c.IDTokenEncryptedResponseAlg != "" { + res["id_token_encrypted_response_alg"] = c.IDTokenEncryptedResponseAlg + } + + if c.IDTokenEncryptedResponseEnc != "" { + res["id_token_encrypted_response_enc"] = c.IDTokenEncryptedResponseEnc + } + + if c.UserinfoSignedResponseAlg != "" { + res["userinfo_signed_response_alg"] = c.UserinfoSignedResponseAlg + } + + if c.UserinfoEncryptedResponseAlg != "" { + res["userinfo_encrypted_response_alg"] = c.UserinfoEncryptedResponseAlg + } + + if c.UserinfoEncryptedResponseEnc != "" { + res["userinfo_encrypted_response_enc"] = c.UserinfoEncryptedResponseEnc + } + + if c.RequestObjectSigningAlg != "" { + res["request_object_signing_alg"] = c.RequestObjectSigningAlg + } + + if c.RequestObjectEncryptionAlg != "" { + res["request_object_encryption_alg"] = c.RequestObjectEncryptionAlg + } + + if c.RequestObjectEncryptionEnc != "" { + res["request_object_encryption_enc"] = c.RequestObjectEncryptionEnc + } + + if c.TokenEndpointAuthSigningAlg != "" { + res["token_endpoint_auth_signing_alg"] = c.TokenEndpointAuthSigningAlg + } + + if c.DefaultMaxAge != 0 { + res["default_max_age"] = c.DefaultMaxAge + } + + if c.RequireAuthTime { + res["require_auth_time"] = c.RequireAuthTime + } + + if len(c.DefaultACRValues) > 0 { + res["default_acr_values"] = c.DefaultACRValues + } + + if c.InitiateLoginURI != "" { + res["initiate_login_uri"] = c.InitiateLoginURI + } + + if len(c.RequestURIs) > 0 { + res["request_uris"] = c.RequestURIs + } + + if len(c.PostLogoutRedirectURIs) > 0 { + res["post_logout_redirect_uris"] = c.PostLogoutRedirectURIs } // Add extra parameters @@ -343,192 +925,235 @@ func (c ClientInformationResponse) MarshalJSON() ([]byte, error) { return json.Marshal(res) } + +// ClientRegistrationRequest implements +// https://www.rfc-editor.org/rfc/rfc7591#section-3.1 +// and https://openid.net/specs/openid-connect-registration-1_0.html#RegistrationRequest +// 3.1 Client Registration Request. +type ClientRegistrationRequest struct { + ClientMetadata + + // SoftwareStatement is a software statement containing client metadata values about the + // client software as claims. This is a string value containing the + // entire signed JWT. + SoftwareStatement string `json:"software_statement"` +} + +func (c *ClientRegistrationRequest) UnmarshalJSON(data []byte) error { + // Step 1: Parse raw JSON to separate software_statement + var rawMap map[string]json.RawMessage + if err := json.Unmarshal(data, &rawMap); err != nil { + return err + } + + // Step 2: Extract software_statement if present + if ssRaw, ok := rawMap["software_statement"]; ok { + if err := json.Unmarshal(ssRaw, &c.SoftwareStatement); err != nil { + return err + } + delete(rawMap, "software_statement") // Remove to avoid duplication + } + + // Step 3: Marshal remaining fields and unmarshal into ClientMetadata + remainingData, err := json.Marshal(rawMap) + if err != nil { + return err + } + return json.Unmarshal(remainingData, &c.ClientMetadata) +} + +// ClientInformationResponse implements +// https://www.rfc-editor.org/rfc/rfc7591#section-3.2.1, +// 3.2.1. Client Information Response and +// https://www.rfc-editor.org/rfc/rfc7592.html#section-3 +// 3. Client Information Response. +type ClientInformationResponse struct { + ClientMetadata + + // Original fields suggested by RFC7591 (https://www.rfc-editor.org/rfc/rfc7591#section-3.2.1) + + // ClientID is a OAuth 2.0 client identifier string. It SHOULD NOT be + // currently valid for any other registered client, though an + // authorization server MAY issue the same client identifier to + // multiple instances of a registered client at its discretion. + // + // REQUIRED. + ClientID string `json:"client_id"` + + // ClientSecret is a OAuth 2.0 client secret string. If issued, this MUST + // be unique for each "client_id" and SHOULD be unique for multiple + // instances of a client using the same "client_id". This value is + // used by confidential clients to authenticate to the token + // endpoint, as described in OAuth 2.0 [RFC6749, Section 2.3.1]. + // + // [RFC6749, Section 2.3.1]: https://www.rfc-editor.org/rfc/rfc6749#section-2.3.1 + // + // OPTIONAL. + ClientSecret string `json:"client_secret,omitempty"` + + // ClientIDIssuedAt is the time at which the client identifier was issued. The + // time is represented as the number of seconds from + // 1970-01-01T00:00:00Z as measured in UTC until the date/time of + // issuance. + // + // OPTIONAL. + ClientIDIssuedAt int64 `json:"client_id_issued_at,omitempty"` + + // ClientSecretExpiresAt is the time at which the client + // secret will expire or 0 if it will not expire. The time is + // represented as the number of seconds from 1970-01-01T00:00:00Z as + // measured in UTC until the date/time of expiration. + // + // REQUIRED if "client_secret" is issued. + ClientSecretExpiresAt int64 `json:"client_secret_expires_at,omitempty"` +} + +// UnmarshalJSON is only used for unit tests (to test MarshalJSON). func (c *ClientInformationResponse) UnmarshalJSON(data []byte) error { - // Initialize maps to avoid nil pointer issues later. - c.ClientName = make(map[string]string) - c.ClientURI = make(map[string]string) - c.LogoURI = make(map[string]string) - c.TOSURI = make(map[string]string) - c.PolicyURI = make(map[string]string) - c.ExtraParameters = make(map[string]interface{}) + // Step 1: Parse raw JSON to separate ClientInformationResponse-specific fields + var rawMap map[string]json.RawMessage + if err := json.Unmarshal(data, &rawMap); err != nil { + return err + } - // Unmarshal into a temporary map to inspect all keys. - var raw map[string]interface{} - if err := json.Unmarshal(data, &raw); err != nil { - return fmt.Errorf("could not unmarshal raw data: %w", err) + // Step 2: Extract ClientInformationResponse-specific fields if present + if ssRaw, ok := rawMap["client_id"]; ok { + if err := json.Unmarshal(ssRaw, &c.ClientID); err != nil { + return err + } + delete(rawMap, "client_id") // Remove to avoid duplication } - // Iterate over all keys found in the JSON. - for key, value := range raw { - switch { - case key == "client_id": - if v, ok := value.(string); ok { - c.ClientID = v - } - case key == "client_secret": - if v, ok := value.(string); ok { - c.ClientSecret = v - } - case key == "client_id_issued_at": - if v, ok := value.(float64); ok { - c.ClientIDIssuedAt = int64(v) - } - case key == "client_secret_expires_at": - if v, ok := value.(float64); ok { - c.ClientSecretExpiresAt = int64(v) - } - case key == "redirect_uris": - if uris, ok := value.([]interface{}); ok { - for _, u := range uris { - if uriStr, ok := u.(string); ok { - c.RedirectURIs = append(c.RedirectURIs, uriStr) - } - } - } - case key == "token_endpoint_auth_method": - if vStr, ok := value.(string); ok { - if v, exists := AuthMethodMap[vStr]; exists { - c.TokenEndpointAuthMethod = v - } - } - case key == "grant_types": - if gts, ok := value.([]interface{}); ok { - for _, gt := range gts { - if gtStr, ok := gt.(string); ok { - if gtParsed, exists := GrantTypeMap[gtStr]; exists { - c.GrantTypes = append(c.GrantTypes, gtParsed) - } - } - } - } - case key == "response_types": - if rts, ok := value.([]interface{}); ok { - for _, rt := range rts { - if rtStr, ok := rt.(string); ok { - if rtParsed, exists := ResponseTypeMap[rtStr]; exists { - c.ResponseTypes = append(c.ResponseTypes, rtParsed) - } - } - } - } - case key == "client_name": - if name, ok := value.(string); ok { - // This is the default, non-tagged name. - c.ClientName["default"] = name - } - case strings.HasPrefix(key, "client_name#"): - if name, ok := value.(string); ok { - // This is a tagged name, e.g., "client_name#ja-Jpan-JP" - // Split the key at the first '#' to get the language tag. - parts := strings.SplitN(key, "#", 2) - if len(parts) == 2 { - langTag := parts[1] - c.ClientName[langTag] = name - } - } - case key == "client_uri": - if uri, ok := value.(string); ok { - // This is the default, non-tagged name. - c.ClientURI["default"] = uri - } - case strings.HasPrefix(key, "client_uri#"): - if uri, ok := value.(string); ok { - // This is a tagged name, e.g., "client_uri#ja-Jpan-JP" - // Split the key at the first '#' to get the language tag. - parts := strings.SplitN(key, "#", 2) - if len(parts) == 2 { - langTag := parts[1] - c.ClientURI[langTag] = uri - } - } - case key == "logo_uri": - if logo, ok := value.(string); ok { - // This is the default, non-tagged name. - c.LogoURI["default"] = logo - } - case strings.HasPrefix(key, "logo_uri#"): - if logo, ok := value.(string); ok { - // This is a tagged name, e.g., "logo_uri#ja-Jpan-JP" - // Split the key at the first '#' to get the language tag. - parts := strings.SplitN(key, "#", 2) - if len(parts) == 2 { - langTag := parts[1] - c.LogoURI[langTag] = logo - } - } - case key == "scope": - if v, ok := value.(string); ok { - c.Scope = v - } - case key == "contacts": - if cts, ok := value.([]interface{}); ok { - for _, ct := range cts { - if ctStr, ok := ct.(string); ok { - c.Contacts = append(c.Contacts, ctStr) - } - } - } - case key == "tos_uri": - if uri, ok := value.(string); ok { - // This is the default, non-tagged name. - c.TOSURI["default"] = uri - } - case strings.HasPrefix(key, "tos_uri#"): - if uri, ok := value.(string); ok { - // This is a tagged name, e.g., "tos_uri#ja-Jpan-JP" - // Split the key at the first '#' to get the language tag. - parts := strings.SplitN(key, "#", 2) - if len(parts) == 2 { - langTag := parts[1] - c.TOSURI[langTag] = uri - } - } - case key == "policy_uri": - if uri, ok := value.(string); ok { - // This is the default, non-tagged name. - c.PolicyURI["default"] = uri - } - case strings.HasPrefix(key, "policy_uri#"): - if uri, ok := value.(string); ok { - // This is a tagged name, e.g., "policy_uri#ja-Jpan-JP" - // Split the key at the first '#' to get the language tag. - parts := strings.SplitN(key, "#", 2) - if len(parts) == 2 { - langTag := parts[1] - c.PolicyURI[langTag] = uri - } - } - case key == "jwks_uri": - if v, ok := value.(string); ok { - c.JWKSURI = v - } - case key == "jwks": - if v, ok := value.(jose.JSONWebKeySet); ok { - c.JWKS = v - } - case key == "software_id": - if v, ok := value.(string); ok { - c.SoftwareID = v - } - case key == "software_version": - if v, ok := value.(string); ok { - c.SoftwareVersion = v - } - case key == "registration_access_token": - if v, ok := value.(string); ok { - c.RegistrationAccessToken = v - } - case key == "registration_client_uri": - if v, ok := value.(string); ok { - c.RegistrationClientURI = v - } - default: - // If the key didn't match any of the above, it's an extra parameter. - c.ExtraParameters[key] = value + if ssRaw, ok := rawMap["client_secret"]; ok { + if err := json.Unmarshal(ssRaw, &c.ClientSecret); err != nil { + return err } + delete(rawMap, "client_secret") // Remove to avoid duplication } - return nil + if ssRaw, ok := rawMap["client_id_issued_at"]; ok { + if err := json.Unmarshal(ssRaw, &c.ClientIDIssuedAt); err != nil { + return err + } + delete(rawMap, "client_id_issued_at") // Remove to avoid duplication + } + + if ssRaw, ok := rawMap["client_secret_expires_at"]; ok { + if err := json.Unmarshal(ssRaw, &c.ClientSecretExpiresAt); err != nil { + return err + } + delete(rawMap, "client_secret_expires_at") // Remove to avoid duplication + } + + // Step 3: Marshal remaining fields and unmarshal into ClientMetadata + remainingData, err := json.Marshal(rawMap) + if err != nil { + return err + } + return json.Unmarshal(remainingData, &c.ClientMetadata) +} + +func (c ClientInformationResponse) MarshalJSON() ([]byte, error) { + // Marshal embedded ClientMetadata (includes custom logic) + metaJSON, err := json.Marshal(c.ClientMetadata) + if err != nil { + return nil, err + } + + // Convert to map to merge fields + var combined map[string]interface{} + if err := json.Unmarshal(metaJSON, &combined); err != nil { + return nil, err + } + + // Add ClientInformationResponse-specific fields + combined["client_id"] = c.ClientID // always present + if c.ClientSecret != "" { + combined["client_secret"] = c.ClientSecret + combined["client_secret_expires_at"] = c.ClientSecretExpiresAt // required if client_secret is issued + } + if c.ClientIDIssuedAt != 0 { + combined["client_id_issued_at"] = c.ClientIDIssuedAt + } + + return json.Marshal(combined) +} + +// ClientRegistrationResponse implements +// https://openid.net/specs/openid-connect-registration-1_0.html#RegistrationResponse +// 3.2. Client Registration Response. +type ClientRegistrationResponse struct { + ClientInformationResponse + + // RegistrationAccessToken is a Registration Access Token that can be used at the + // Client Configuration Endpoint to perform subsequent operations upon the Client registration.ClientSecret + // + // OPTIONAL. + RegistrationAccessToken string `json:"registration_access_token,omitempty"` + + // RegistrationClientURI is the location of the Client Configuration Endpoint where the + // Registration Access Token can be used to perform subsequent operations upon the resulting Client registration. + // This URL MUST use the https scheme. + // Implementations MUST either return both a Client Configuration Endpoint and + // a Registration Access Token or neither of them. + // + // OPTIONAL. + RegistrationClientURI string `json:"registration_client_uri,omitempty"` +} + +// UnmarshalJSON is only used for unit tests (to test MarshalJSON). +func (c *ClientRegistrationResponse) UnmarshalJSON(data []byte) error { + // Step 1: Parse raw JSON to separate ClientRegistrationResponse-specific fields + var rawMap map[string]json.RawMessage + if err := json.Unmarshal(data, &rawMap); err != nil { + return err + } + + // Step 2: Extract ClientRegistrationResponse-specific fields if present + if ssRaw, ok := rawMap["registration_access_token"]; ok { + if err := json.Unmarshal(ssRaw, &c.RegistrationAccessToken); err != nil { + return err + } + delete(rawMap, "registration_access_token") // Remove to avoid duplication + } + + if ssRaw, ok := rawMap["registration_client_uri"]; ok { + if err := json.Unmarshal(ssRaw, &c.RegistrationClientURI); err != nil { + return err + } + delete(rawMap, "registration_client_uri") // Remove to avoid duplication + } + + // Step 3: Marshal remaining fields and unmarshal into ClientMetadata + remainingData, err := json.Marshal(rawMap) + if err != nil { + return err + } + return json.Unmarshal(remainingData, &c.ClientInformationResponse) +} + +func (c ClientRegistrationResponse) MarshalJSON() ([]byte, error) { + // Marshal embedded ClientInformationResponse (includes custom logic) + metaJSON, err := json.Marshal(c.ClientInformationResponse) + if err != nil { + return nil, err + } + + // Convert to map to merge fields + var combined map[string]interface{} + if err := json.Unmarshal(metaJSON, &combined); err != nil { + return nil, err + } + + // Add ClientRegistrationResponse-specific fields + if c.RegistrationAccessToken != "" { + combined["registration_access_token"] = c.RegistrationAccessToken + } + if c.RegistrationClientURI != "" { + combined["registration_client_uri"] = c.RegistrationClientURI + } + + return json.Marshal(combined) } // ClientInformationErrorResponse implements @@ -550,219 +1175,75 @@ const ( type ClientInformationErrorResponseErrorCode string -// ClientUpdateRequest implements https://www.rfc-editor.org/rfc/rfc7592.html#section-2.1 +// ClientReadResponse implements +// https://openid.net/specs/openid-connect-registration-1_0.html#ReadResponse +// 4.3. Client Read Response. +type ClientReadResponse struct { + ClientRegistrationResponse +} + +// ClientUpdateRequest implements https://www.rfc-editor.org/rfc/rfc7592.html#section-2.2 // 2.2 Client Update Request. // // Similar to ClientInformationResponse, except: // -// This request MUST include all client metadata fields as returned to -// the client from a previous registration, read, or update operation. -// The updated client metadata fields request MUST NOT include the -// "registration_access_token", "registration_client_uri", -// "client_secret_expires_at", or "client_id_issued_at" fields described -// in Section 3. +// This request MUST include all client metadata fields as returned to +// the client from a previous registration, read, or update operation. +// The updated client metadata fields request MUST NOT include the +// "registration_access_token", "registration_client_uri", +// "client_secret_expires_at", or "client_id_issued_at" fields described +// in Section 3. type ClientUpdateRequest struct { - ClientID string `json:"client_id"` // OAuth 2.0 client identifier string. - ClientSecret string `json:"client_secret,omitempty"` // OAuth 2.0 client secret string. - //ClientIDIssuedAt int64 `json:"client_id_issued_at,omitempty"` // Time at which the client identifier was issued. - //ClientSecretExpiresAt int64 `json:"client_secret_expires_at,omitempty"` // Time at which the client secret will expire or 0 if it will not expire. - - // fields that are reused from ClientRegistrationRequest - RedirectURIs []string `json:"redirect_uris,omitempty"` // Array of redirection URI strings for use in redirect-based flows such as the authorization code and implicit flows. - TokenEndpointAuthMethod AuthMethod `json:"token_endpoint_auth_method,omitempty"` // String indicator of the requested authentication method for the token endpoint. - GrantTypes []GrantType `json:"grant_types,omitempty"` // Array of OAuth 2.0 grant type strings that the client can use at the token endpoint. - ResponseTypes []ResponseType `json:"response_types,omitempty"` // Array of the OAuth 2.0 response type strings that the client can use at the authorization endpoint. - ClientName map[string]string `json:"client_name,omitempty"` // Human-readable string name of the client to be presented to the end-user during authorization. (BCP 47) - ClientURI map[string]string `json:"client_uri,omitempty"` // URL string of a web page providing information about the client. (BCP 47) - LogoURI map[string]string `json:"logo_uri,omitempty"` // URL string that references a logo for the client. (BCP 47) - Scope string `json:"scope,omitempty"` // String containing a space-separated list of scope values (as described in Section 3.3 of OAuth 2.0 [RFC6749]) that the client can use when requesting access tokens. - Contacts []string `json:"contacts,omitempty"` // Array of strings representing ways to contact people responsible for this client, typically email addresses. - TOSURI map[string]string `json:"tos_uri,omitempty"` // URL string that points to a human-readable terms of service document for the client that describes a contractual relationship between the end-user and the client that the end-user accepts when authorizing the client. (BCP 47) - PolicyURI map[string]string `json:"policy_uri,omitempty"` // URL string that points to a human-readable privacy policy document that describes how the deployment organization collects, uses, retains, and discloses personal data. (BCP 47) - JWKSURI string `json:"jwks_uri,omitempty"` // URL string referencing the client's JSON Web Key (JWK) Set [RFC7517] document, which contains the client's public keys. - JWKS jose.JSONWebKeySet `json:"jwks,omitempty"` // Client's JSON Web Key Set [RFC7517] document value, which contains the client's public keys. - SoftwareID string `json:"software_id,omitempty"` // A unique identifier string (e.g., a Universally Unique Identifier (UUID)) assigned by the client developer or software publisher used by registration endpoints to identify the client software to be dynamically registered. - SoftwareVersion string `json:"software_version,omitempty"` // A version identifier string for the client software identified by "software_id". - //RegistrationAccessToken string `json:"registration_access_token,omitempty"` - //RegistrationClientURI string `json:"registration_client_uri,omitempty"` + ClientMetadata - // ExtraParameters holds other extension parameters. - ExtraParameters map[string]interface{} + // ClientID is a OAuth 2.0 client identifier string. It SHOULD NOT be + // currently valid for any other registered client, though an + // authorization server MAY issue the same client identifier to + // multiple instances of a registered client at its discretion. + // + // REQUIRED. + ClientID string `json:"client_id"` + + // ClientSecret is a OAuth 2.0 client secret string. If issued, this MUST + // be unique for each "client_id" and SHOULD be unique for multiple + // instances of a client using the same "client_id". This value is + // used by confidential clients to authenticate to the token + // endpoint, as described in OAuth 2.0 [RFC6749, Section 2.3.1]. + // + // [RFC6749, Section 2.3.1]: https://www.rfc-editor.org/rfc/rfc6749#section-2.3.1 + // + // OPTIONAL. + ClientSecret string `json:"client_secret,omitempty"` } -// UnmarshalJSON -// -// TODO: collapse with ClientInformationResponse.UnmarshalJSON func (c *ClientUpdateRequest) UnmarshalJSON(data []byte) error { - // Initialize maps to avoid nil pointer issues later. - c.ClientName = make(map[string]string) - c.ClientURI = make(map[string]string) - c.LogoURI = make(map[string]string) - c.TOSURI = make(map[string]string) - c.PolicyURI = make(map[string]string) - c.ExtraParameters = make(map[string]interface{}) + // Step 1: Parse raw JSON to separate ClientUpdateRequest-specific fields + var rawMap map[string]json.RawMessage + if err := json.Unmarshal(data, &rawMap); err != nil { + return err + } - // Unmarshal into a temporary map to inspect all keys. - var raw map[string]interface{} - if err := json.Unmarshal(data, &raw); err != nil { - return fmt.Errorf("could not unmarshal raw data: %w", err) + // Step 2: Extract ClientUpdateRequest-specific fields if present + if ssRaw, ok := rawMap["client_id"]; ok { + if err := json.Unmarshal(ssRaw, &c.ClientID); err != nil { + return err + } + delete(rawMap, "client_id") // Remove to avoid duplication } - // Iterate over all keys found in the JSON. - for key, value := range raw { - switch { - case key == "client_id": - if v, ok := value.(string); ok { - c.ClientID = v - } - case key == "client_secret": - if v, ok := value.(string); ok { - c.ClientSecret = v - } - case key == "redirect_uris": - if uris, ok := value.([]interface{}); ok { - for _, u := range uris { - if uriStr, ok := u.(string); ok { - c.RedirectURIs = append(c.RedirectURIs, uriStr) - } - } - } - case key == "token_endpoint_auth_method": - if vStr, ok := value.(string); ok { - if v, exists := AuthMethodMap[vStr]; exists { - c.TokenEndpointAuthMethod = v - } - } - case key == "grant_types": - if gts, ok := value.([]interface{}); ok { - for _, gt := range gts { - if gtStr, ok := gt.(string); ok { - if gtParsed, exists := GrantTypeMap[gtStr]; exists { - c.GrantTypes = append(c.GrantTypes, gtParsed) - } - } - } - } - case key == "response_types": - if rts, ok := value.([]interface{}); ok { - for _, rt := range rts { - if rtStr, ok := rt.(string); ok { - if rtParsed, exists := ResponseTypeMap[rtStr]; exists { - c.ResponseTypes = append(c.ResponseTypes, rtParsed) - } - } - } - } - case key == "client_name": - if name, ok := value.(string); ok { - // This is the default, non-tagged name. - c.ClientName["default"] = name - } - case strings.HasPrefix(key, "client_name#"): - if name, ok := value.(string); ok { - // This is a tagged name, e.g., "client_name#ja-Jpan-JP" - // Split the key at the first '#' to get the language tag. - parts := strings.SplitN(key, "#", 2) - if len(parts) == 2 { - langTag := parts[1] - c.ClientName[langTag] = name - } - } - case key == "client_uri": - if uri, ok := value.(string); ok { - // This is the default, non-tagged name. - c.ClientURI["default"] = uri - } - case strings.HasPrefix(key, "client_uri#"): - if uri, ok := value.(string); ok { - // This is a tagged name, e.g., "client_uri#ja-Jpan-JP" - // Split the key at the first '#' to get the language tag. - parts := strings.SplitN(key, "#", 2) - if len(parts) == 2 { - langTag := parts[1] - c.ClientURI[langTag] = uri - } - } - case key == "logo_uri": - if logo, ok := value.(string); ok { - // This is the default, non-tagged name. - c.LogoURI["default"] = logo - } - case strings.HasPrefix(key, "logo_uri#"): - if logo, ok := value.(string); ok { - // This is a tagged name, e.g., "logo_uri#ja-Jpan-JP" - // Split the key at the first '#' to get the language tag. - parts := strings.SplitN(key, "#", 2) - if len(parts) == 2 { - langTag := parts[1] - c.LogoURI[langTag] = logo - } - } - case key == "scope": - if v, ok := value.(string); ok { - c.Scope = v - } - case key == "contacts": - if cts, ok := value.([]interface{}); ok { - for _, ct := range cts { - if ctStr, ok := ct.(string); ok { - c.Contacts = append(c.Contacts, ctStr) - } - } - } - case key == "tos_uri": - if uri, ok := value.(string); ok { - // This is the default, non-tagged name. - c.TOSURI["default"] = uri - } - case strings.HasPrefix(key, "tos_uri#"): - if uri, ok := value.(string); ok { - // This is a tagged name, e.g., "tos_uri#ja-Jpan-JP" - // Split the key at the first '#' to get the language tag. - parts := strings.SplitN(key, "#", 2) - if len(parts) == 2 { - langTag := parts[1] - c.TOSURI[langTag] = uri - } - } - case key == "policy_uri": - if uri, ok := value.(string); ok { - // This is the default, non-tagged name. - c.PolicyURI["default"] = uri - } - case strings.HasPrefix(key, "policy_uri#"): - if uri, ok := value.(string); ok { - // This is a tagged name, e.g., "policy_uri#ja-Jpan-JP" - // Split the key at the first '#' to get the language tag. - parts := strings.SplitN(key, "#", 2) - if len(parts) == 2 { - langTag := parts[1] - c.PolicyURI[langTag] = uri - } - } - case key == "jwks_uri": - if v, ok := value.(string); ok { - c.JWKSURI = v - } - case key == "jwks": - if v, ok := value.(jose.JSONWebKeySet); ok { - c.JWKS = v - } - case key == "software_id": - if v, ok := value.(string); ok { - c.SoftwareID = v - } - case key == "software_version": - if v, ok := value.(string); ok { - c.SoftwareVersion = v - } - default: - // If the key didn't match any of the above, it's an extra parameter. - c.ExtraParameters[key] = value + if ssRaw, ok := rawMap["client_secret"]; ok { + if err := json.Unmarshal(ssRaw, &c.ClientSecret); err != nil { + return err } + delete(rawMap, "client_secret") // Remove to avoid duplication } - return nil + // Step 3: Marshal remaining fields and unmarshal into ClientMetadata + remainingData, err := json.Marshal(rawMap) + if err != nil { + return err + } + return json.Unmarshal(remainingData, &c.ClientMetadata) } // ClientReadRequest implements diff --git a/pkg/oidc/dynamic_client_registration_test.go b/pkg/oidc/dynamic_client_registration_test.go index 0f4db95f..dfa16e44 100644 --- a/pkg/oidc/dynamic_client_registration_test.go +++ b/pkg/oidc/dynamic_client_registration_test.go @@ -11,6 +11,8 @@ import ( "testing" ) +// compareRSAJSONWebKey is a test helper to compare a given jose.JSONWebKey with the +// equivalent base64-encoded RSA E and N values. func compareRSAJSONWebKey( t *testing.T, wantEStr, wantNStr string, @@ -184,92 +186,228 @@ func TestClientRegistrationRequest(t *testing.T) { assert.Contains(t, req.ExtraParameters, "example_extension_parameter") assert.Equal(t, "example_value", req.ExtraParameters["example_extension_parameter"]) }) -} - -func TestClientUpdateRequest(t *testing.T) { - // from https://www.rfc-editor.org/rfc/rfc7592.html#page-8 - t.Run("unmarshal Client Update Request example", func(t *testing.T) { + // Example from https://openid.net/specs/openid-connect-registration-1_0.html#RegistrationRequest + t.Run("unmarshal Client Registration Request example", func(t *testing.T) { marshalled := []byte(` { - "client_id": "s6BhdRkqt3", - "client_secret": "cf136dc3c1fc93f31185e5885805d", - "redirect_uris": [ - "https://client.example.org/callback", - "https://client.example.org/alt" - ], - "grant_types": ["authorization_code", "refresh_token"], + "application_type": "web", + "redirect_uris": ["https://client.example.org/callback", "https://client.example.org/callback2"], + "client_name": "My Example", + "client_name#ja-Jpan-JP": "クライアント名", + "logo_uri": "https://client.example.org/logo.png", + "subject_type": "pairwise", + "sector_identifier_uri": "https://other.example.net/file_of_redirect_uris.json", "token_endpoint_auth_method": "client_secret_basic", "jwks_uri": "https://client.example.org/my_public_keys.jwks", - "client_name": "My New Example", - "client_name#fr": "Mon Nouvel Exemple", - "logo_uri": "https://client.example.org/newlogo.png", - "logo_uri#fr": "https://client.example.org/fr/newlogo.png" + "userinfo_encrypted_response_alg": "RSA-OAEP-256", + "userinfo_encrypted_response_enc": "A128CBC-HS256", + "contacts": ["ve7jtb@example.org", "mary@example.org"], + "request_uris": ["https://client.example.org/rf.txt#qpXaRLh_n93TTR9F252ValdatUQvQiJi5BDub2BeznA"] } `) - var req ClientUpdateRequest + var req ClientRegistrationRequest err := json.Unmarshal(marshalled, &req) require.NoError(t, err) - assert.Equal(t, "s6BhdRkqt3", req.ClientID) - assert.Equal(t, "cf136dc3c1fc93f31185e5885805d", req.ClientSecret) + assert.Equal(t, "web", req.ApplicationType) // cannot use op.ApplicationTypeWeb because of cyclic imports assert.Len(t, req.RedirectURIs, 2) assert.Contains(t, req.RedirectURIs, "https://client.example.org/callback") - assert.Contains(t, req.RedirectURIs, "https://client.example.org/alt") - assert.Len(t, req.GrantTypes, 2) - assert.Contains(t, req.GrantTypes, GrantTypeCode) - assert.Contains(t, req.GrantTypes, GrantTypeRefreshToken) + assert.Contains(t, req.RedirectURIs, "https://client.example.org/callback2") + assert.Len(t, req.ClientName, 2) + assert.Equal(t, "My Example", req.ClientName["default"]) + assert.Equal(t, "\u30AF\u30E9\u30A4\u30A2\u30F3\u30C8\u540D", req.ClientName["ja-Jpan-JP"]) + assert.Len(t, req.LogoURI, 1) + assert.Equal(t, "https://client.example.org/logo.png", req.LogoURI["default"]) + assert.Equal(t, "pairwise", req.SubjectType) + assert.Equal(t, "https://other.example.net/file_of_redirect_uris.json", req.SectorIdentifierURI) + assert.Equal(t, AuthMethodBasic, req.TokenEndpointAuthMethod) assert.Equal(t, "https://client.example.org/my_public_keys.jwks", req.JWKSURI) - assert.Equal(t, "My New Example", req.ClientName["default"]) - assert.Equal(t, "Mon Nouvel Exemple", req.ClientName["fr"]) - assert.Equal(t, "https://client.example.org/newlogo.png", req.LogoURI["default"]) - assert.Equal(t, "https://client.example.org/fr/newlogo.png", req.LogoURI["fr"]) + assert.Equal(t, "RSA-OAEP-256", req.UserinfoEncryptedResponseAlg) + assert.Equal(t, "A128CBC-HS256", req.UserinfoEncryptedResponseEnc) + assert.Len(t, req.Contacts, 2) + assert.Contains(t, req.Contacts, "ve7jtb@example.org") + assert.Contains(t, req.Contacts, "mary@example.org") + assert.Len(t, req.RequestURIs, 1) + assert.Contains(t, req.RequestURIs, "https://client.example.org/rf.txt#qpXaRLh_n93TTR9F252ValdatUQvQiJi5BDub2BeznA") }) } +func TestClientReadResponse(t *testing.T) { + // example from https://openid.net/specs/openid-connect-registration-1_0.html#ReadResponse + t.Run("unmarshal, marshal and unmarshal example", func(t *testing.T) { + marshalled1 := []byte(` +{ + "client_id": "s6BhdRkqt3", + "client_secret": "OylyaC56ijpAQ7G5ZZGL7MMQ6Ap6mEeuhSTFVps2N4Q", + "client_secret_expires_at": 17514165600, + "registration_client_uri": "https://server.example.com/connect/register?client_id=s6BhdRkqt3", + "token_endpoint_auth_method": "client_secret_basic", + "application_type": "web", + "redirect_uris": ["https://client.example.org/callback", "https://client.example.org/callback2"], + "client_name": "My Example", + "client_name#ja-Jpan-JP": "クライアント名", + "logo_uri": "https://client.example.org/logo.png", + "subject_type": "pairwise", + "sector_identifier_uri": "https://other.example.net/file_of_redirect_uris.json", + "jwks_uri": "https://client.example.org/my_public_keys.jwks", + "userinfo_encrypted_response_alg": "RSA-OAEP-256", + "userinfo_encrypted_response_enc": "A128CBC-HS256", + "contacts": ["ve7jtb@example.org", "mary@example.org"], + "request_uris": ["https://client.example.org/rf.txt#qpXaRLh_n93TTR9F252ValdatUQvQiJi5BDub2BeznA"] +} +`) + var req1 ClientReadResponse + require.NoError(t, json.Unmarshal(marshalled1, &req1)) -func TestClientInformationResponse(t *testing.T) { - // example from https://www.rfc-editor.org/rfc/rfc7591#page-21 - t.Run("unmarshal example", func(t *testing.T) { - marshalled := []byte(` + marshalled2, err2 := json.Marshal(req1) + require.NoError(t, err2) + + var req3 ClientReadResponse + require.NoError(t, json.Unmarshal(marshalled2, &req3)) + + assert.Equal(t, "s6BhdRkqt3", req3.ClientID) + assert.Equal(t, "OylyaC56ijpAQ7G5ZZGL7MMQ6Ap6mEeuhSTFVps2N4Q", req3.ClientSecret) + assert.Equal(t, int64(17514165600), req3.ClientSecretExpiresAt) + assert.Equal(t, "https://server.example.com/connect/register?client_id=s6BhdRkqt3", req3.RegistrationClientURI) + assert.Equal(t, AuthMethodBasic, req3.TokenEndpointAuthMethod) + assert.Equal(t, "web", req3.ApplicationType) // cannot use op.ApplicationTypeWeb because of cyclic imports + assert.Len(t, req3.RedirectURIs, 2) + assert.Contains(t, req3.RedirectURIs, "https://client.example.org/callback") + assert.Contains(t, req3.RedirectURIs, "https://client.example.org/callback2") + assert.Equal(t, "My Example", req3.ClientName["default"]) + assert.Equal(t, "\u30AF\u30E9\u30A4\u30A2\u30F3\u30C8\u540D", req3.ClientName["ja-Jpan-JP"]) + assert.Len(t, req3.LogoURI, 1) + assert.Equal(t, "https://client.example.org/logo.png", req3.LogoURI["default"]) + assert.Equal(t, "pairwise", req3.SubjectType) + assert.Equal(t, "https://other.example.net/file_of_redirect_uris.json", req3.SectorIdentifierURI) + assert.Equal(t, "RSA-OAEP-256", req3.UserinfoEncryptedResponseAlg) + assert.Equal(t, "A128CBC-HS256", req3.UserinfoEncryptedResponseEnc) + assert.Len(t, req3.Contacts, 2) + assert.Contains(t, req3.Contacts, "ve7jtb@example.org") + assert.Contains(t, req3.Contacts, "mary@example.org") + assert.Len(t, req3.RequestURIs, 1) + assert.Contains(t, req3.RequestURIs, "https://client.example.org/rf.txt#qpXaRLh_n93TTR9F252ValdatUQvQiJi5BDub2BeznA") + }) +} + +func TestClientInformationErrorResponse(t *testing.T) { + // example from https://www.rfc-editor.org/rfc/rfc7591#page-23 + t.Run("unmarshal, marshal and unmarshal example", func(t *testing.T) { + marshalled1 := []byte(` +{ + "error": "invalid_redirect_uri", + "error_description": "The redirection URI http://sketchy.example.com is not allowed by this server." +} +`) + var req1 ClientInformationErrorResponse + require.NoError(t, json.Unmarshal(marshalled1, &req1)) + + marshalled2, err2 := json.Marshal(req1) + require.NoError(t, err2) + + var req3 ClientInformationErrorResponse + require.NoError(t, json.Unmarshal(marshalled2, &req3)) + + assert.Equal(t, ClientInformationErrorResponseErrorCodeInvalidRedirectURI, req3.Error) + assert.Equal(t, "The redirection URI http://sketchy.example.com is not allowed by this server.", req3.ErrorDescription) + }) + // example from https://www.rfc-editor.org/rfc/rfc7591#page-23 + t.Run("unmarshal, marshal and unmarshal example", func(t *testing.T) { + marshalled1 := []byte(` +{ + "error": "invalid_client_metadata", + "error_description": "The grant type 'authorization_code' must be registered along with the response type 'code' but found only 'implicit' instead." +} +`) + var req1 ClientInformationErrorResponse + require.NoError(t, json.Unmarshal(marshalled1, &req1)) + + marshalled2, err2 := json.Marshal(req1) + require.NoError(t, err2) + + var req3 ClientInformationErrorResponse + require.NoError(t, json.Unmarshal(marshalled2, &req3)) + + assert.Equal(t, ClientInformationErrorResponseErrorCodeInvalidClientMetadata, req3.Error) + assert.Equal(t, "The grant type 'authorization_code' must be registered along with the response type 'code' but found only 'implicit' instead.", req3.ErrorDescription) + }) + // example from https://openid.net/specs/openid-connect-registration-1_0.html#RegistrationError + t.Run("unmarshal, marshal and unmarshal example", func(t *testing.T) { + marshalled1 := []byte(` + { + "error": "invalid_redirect_uri", + "error_description": "One or more redirect_uri values are invalid" +} +`) + var req1 ClientInformationErrorResponse + require.NoError(t, json.Unmarshal(marshalled1, &req1)) + + marshalled2, err2 := json.Marshal(req1) + require.NoError(t, err2) + + var req3 ClientInformationErrorResponse + require.NoError(t, json.Unmarshal(marshalled2, &req3)) + + assert.Equal(t, ClientInformationErrorResponseErrorCodeInvalidRedirectURI, req3.Error) + assert.Equal(t, "One or more redirect_uri values are invalid", req3.ErrorDescription) + }) +} + +func TestClientRegistrationResponse(t *testing.T) { + // from https://openid.net/specs/openid-connect-registration-1_0.html#RegistrationResponse + t.Run("unmarshal, marshal and unmarshal example", func(t *testing.T) { + marshalled1 := []byte(` { "client_id": "s6BhdRkqt3", - "client_secret": "cf136dc3c1fc93f31185e5885805d", - "client_id_issued_at": 2893256800, - "client_secret_expires_at": 2893276800, - "redirect_uris": [ - "https://client.example.org/callback", - "https://client.example.org/callback2" - ], - "grant_types": ["authorization_code", "refresh_token"], - "client_name": "My Example Client", - "client_name#ja-Jpan-JP": "\u30AF\u30E9\u30A4\u30A2\u30F3\u30C8\u540D", + "client_secret": "ZJYCqe3GGRvdrudKyZS0XhGv_Z45DuKhCUk0gBR1vZk", + "client_secret_expires_at": 1577858400, + "registration_access_token": "this.is.an.access.token.value.ffx83", + "registration_client_uri": "https://server.example.com/connect/register?client_id=s6BhdRkqt3", "token_endpoint_auth_method": "client_secret_basic", + "application_type": "web", + "redirect_uris": ["https://client.example.org/callback", "https://client.example.org/callback2"], + "client_name": "My Example", + "client_name#ja-Jpan-JP": "クライアント名", "logo_uri": "https://client.example.org/logo.png", + "subject_type": "pairwise", + "sector_identifier_uri": "https://other.example.net/file_of_redirect_uris.json", "jwks_uri": "https://client.example.org/my_public_keys.jwks", - "example_extension_parameter": "example_value" + "userinfo_encrypted_response_alg": "RSA-OAEP-256", + "userinfo_encrypted_response_enc": "A128CBC-HS256", + "contacts": ["ve7jtb@example.org", "mary@example.org"], + "request_uris": ["https://client.example.org/rf.txt#qpXaRLh_n93TTR9F252ValdatUQvQiJi5BDub2BeznA"] } `) - var req ClientInformationResponse - err := json.Unmarshal(marshalled, &req) - require.NoError(t, err) - assert.Equal(t, "s6BhdRkqt3", req.ClientID) - assert.Equal(t, "cf136dc3c1fc93f31185e5885805d", req.ClientSecret) - assert.Equal(t, int64(2893256800), req.ClientIDIssuedAt) - assert.Equal(t, int64(2893276800), req.ClientSecretExpiresAt) - assert.Len(t, req.RedirectURIs, 2) - assert.Contains(t, req.RedirectURIs, "https://client.example.org/callback") - assert.Contains(t, req.RedirectURIs, "https://client.example.org/callback2") - assert.Len(t, req.GrantTypes, 2) - assert.Contains(t, req.GrantTypes, GrantTypeCode) - assert.Contains(t, req.GrantTypes, GrantTypeRefreshToken) - assert.Len(t, req.ClientName, 2) - assert.Equal(t, "My Example Client", req.ClientName["default"]) - assert.Equal(t, "\u30AF\u30E9\u30A4\u30A2\u30F3\u30C8\u540D", req.ClientName["ja-Jpan-JP"]) - assert.Equal(t, AuthMethodBasic, req.TokenEndpointAuthMethod) - assert.Len(t, req.LogoURI, 1) - assert.Equal(t, "https://client.example.org/logo.png", req.LogoURI["default"]) - assert.Equal(t, "https://client.example.org/my_public_keys.jwks", req.JWKSURI) - assert.Len(t, req.ExtraParameters, 1) - assert.Contains(t, req.ExtraParameters, "example_extension_parameter") - assert.Equal(t, "example_value", req.ExtraParameters["example_extension_parameter"]) + var req1 ClientRegistrationResponse + require.NoError(t, json.Unmarshal(marshalled1, &req1)) + + marshalled2, err2 := json.Marshal(req1) + require.NoError(t, err2) + + var req3 ClientRegistrationResponse + require.NoError(t, json.Unmarshal(marshalled2, &req3)) + + assert.Equal(t, "s6BhdRkqt3", req3.ClientID) + assert.Equal(t, "ZJYCqe3GGRvdrudKyZS0XhGv_Z45DuKhCUk0gBR1vZk", req3.ClientSecret) + assert.Equal(t, int64(1577858400), req3.ClientSecretExpiresAt) + assert.Equal(t, "this.is.an.access.token.value.ffx83", req3.RegistrationAccessToken) + assert.Equal(t, "https://server.example.com/connect/register?client_id=s6BhdRkqt3", req3.RegistrationClientURI) + assert.Equal(t, AuthMethodBasic, req3.TokenEndpointAuthMethod) + assert.Equal(t, "web", req3.ApplicationType) // cannot use op.ApplicationTypeWeb because of cyclic imports + assert.Len(t, req3.RedirectURIs, 2) + assert.Contains(t, req3.RedirectURIs, "https://client.example.org/callback") + assert.Contains(t, req3.RedirectURIs, "https://client.example.org/callback2") + assert.Equal(t, "My Example", req3.ClientName["default"]) + assert.Equal(t, "\u30AF\u30E9\u30A4\u30A2\u30F3\u30C8\u540D", req3.ClientName["ja-Jpan-JP"]) + assert.Len(t, req3.LogoURI, 1) + assert.Equal(t, "https://client.example.org/logo.png", req3.LogoURI["default"]) + assert.Equal(t, "pairwise", req3.SubjectType) + assert.Equal(t, "https://other.example.net/file_of_redirect_uris.json", req3.SectorIdentifierURI) + assert.Equal(t, "RSA-OAEP-256", req3.UserinfoEncryptedResponseAlg) + assert.Equal(t, "A128CBC-HS256", req3.UserinfoEncryptedResponseEnc) + assert.Len(t, req3.Contacts, 2) + assert.Contains(t, req3.Contacts, "ve7jtb@example.org") + assert.Contains(t, req3.Contacts, "mary@example.org") + assert.Len(t, req3.RequestURIs, 1) + assert.Contains(t, req3.RequestURIs, "https://client.example.org/rf.txt#qpXaRLh_n93TTR9F252ValdatUQvQiJi5BDub2BeznA") }) // example from https://www.rfc-editor.org/rfc/rfc7591#page-21 t.Run("unmarshal example, then marshal, unmarshal again", func(t *testing.T) { @@ -292,13 +430,13 @@ func TestClientInformationResponse(t *testing.T) { "example_extension_parameter": "example_value" } `) - var req1 ClientInformationResponse + var req1 ClientRegistrationResponse require.NoError(t, json.Unmarshal(marshalled1, &req1)) marshalled2, err2 := json.Marshal(req1) require.NoError(t, err2) - var req3 ClientInformationResponse + var req3 ClientRegistrationResponse require.NoError(t, json.Unmarshal(marshalled2, &req3)) assert.Equal(t, "s6BhdRkqt3", req3.ClientID) @@ -345,20 +483,20 @@ func TestClientInformationResponse(t *testing.T) { "jwks_uri": "https://client.example.org/my_public_keys.jwks" } `) - var req ClientInformationResponse + var req ClientRegistrationResponse require.NoError(t, json.Unmarshal(marshalled1, &req)) - var req1 ClientInformationResponse + var req1 ClientRegistrationResponse require.NoError(t, json.Unmarshal(marshalled1, &req1)) marshalled2, err2 := json.Marshal(req1) require.NoError(t, err2) - var req3 ClientInformationResponse + var req3 ClientRegistrationResponse require.NoError(t, json.Unmarshal(marshalled2, &req3)) - assert.Equal(t, "reg-23410913-abewfq.123483", req3.RegistrationAccessToken) - assert.Equal(t, "https://server.example.com/register/s6BhdRkqt3", req3.RegistrationClientURI) + //assert.Equal(t, "reg-23410913-abewfq.123483", req3.RegistrationAccessToken) + //assert.Equal(t, "https://server.example.com/register/s6BhdRkqt3", req3.RegistrationClientURI) assert.Equal(t, "s6BhdRkqt3", req3.ClientID) assert.Equal(t, int64(2893256800), req3.ClientIDIssuedAt) assert.Equal(t, int64(2893276800), req3.ClientSecretExpiresAt) @@ -377,33 +515,43 @@ func TestClientInformationResponse(t *testing.T) { }) } -func TestClientInformationErrorResponse(t *testing.T) { - // example from https://www.rfc-editor.org/rfc/rfc7591#page-23 - t.Run("unmarshal example", func(t *testing.T) { - marshalled := []byte(` -{ - "error": "invalid_redirect_uri", - "error_description": "The redirection URI http://sketchy.example.com is not allowed by this server." -} -`) - var req ClientInformationErrorResponse - err := json.Unmarshal(marshalled, &req) - require.NoError(t, err) - assert.Equal(t, ClientInformationErrorResponseErrorCodeInvalidRedirectURI, req.Error) - assert.Equal(t, "The redirection URI http://sketchy.example.com is not allowed by this server.", req.ErrorDescription) - }) - // example from https://www.rfc-editor.org/rfc/rfc7591#page-23 - t.Run("unmarshal example", func(t *testing.T) { +func TestClientUpdateRequest(t *testing.T) { + // from https://www.rfc-editor.org/rfc/rfc7592.html#page-8 + t.Run("unmarshal Client Update Request example", func(t *testing.T) { marshalled := []byte(` { - "error": "invalid_client_metadata", - "error_description": "The grant type 'authorization_code' must be registered along with the response type 'code' but found only 'implicit' instead." + "client_id": "s6BhdRkqt3", + "client_secret": "cf136dc3c1fc93f31185e5885805d", + "redirect_uris": [ + "https://client.example.org/callback", + "https://client.example.org/alt" + ], + "grant_types": ["authorization_code", "refresh_token"], + "token_endpoint_auth_method": "client_secret_basic", + "jwks_uri": "https://client.example.org/my_public_keys.jwks", + "client_name": "My New Example", + "client_name#fr": "Mon Nouvel Exemple", + "logo_uri": "https://client.example.org/newlogo.png", + "logo_uri#fr": "https://client.example.org/fr/newlogo.png" } `) - var req ClientInformationErrorResponse + var req ClientUpdateRequest err := json.Unmarshal(marshalled, &req) require.NoError(t, err) - assert.Equal(t, ClientInformationErrorResponseErrorCodeInvalidClientMetadata, req.Error) - assert.Equal(t, "The grant type 'authorization_code' must be registered along with the response type 'code' but found only 'implicit' instead.", req.ErrorDescription) + assert.Equal(t, "s6BhdRkqt3", req.ClientID) + assert.Equal(t, "cf136dc3c1fc93f31185e5885805d", req.ClientSecret) + assert.Len(t, req.RedirectURIs, 2) + assert.Contains(t, req.RedirectURIs, "https://client.example.org/callback") + assert.Contains(t, req.RedirectURIs, "https://client.example.org/alt") + assert.Len(t, req.GrantTypes, 2) + assert.Contains(t, req.GrantTypes, GrantTypeCode) + assert.Contains(t, req.GrantTypes, GrantTypeRefreshToken) + assert.Equal(t, "https://client.example.org/my_public_keys.jwks", req.JWKSURI) + assert.Len(t, req.ClientName, 2) + assert.Equal(t, "My New Example", req.ClientName["default"]) + assert.Equal(t, "Mon Nouvel Exemple", req.ClientName["fr"]) + assert.Len(t, req.LogoURI, 2) + assert.Equal(t, "https://client.example.org/newlogo.png", req.LogoURI["default"]) + assert.Equal(t, "https://client.example.org/fr/newlogo.png", req.LogoURI["fr"]) }) } From e9470de5f10cc247fe00afa9aea006c2d89ffe58 Mon Sep 17 00:00:00 2001 From: mqf20 Date: Mon, 4 Aug 2025 22:29:08 +0800 Subject: [PATCH 17/44] WIP added placeholders for auth Signed-off-by: mqf20 --- example/server/storage/storage.go | 24 +++++++++++++++++++++++- pkg/op/storage.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/example/server/storage/storage.go b/example/server/storage/storage.go index 9efb53fa..2f88c3f8 100644 --- a/example/server/storage/storage.go +++ b/example/server/storage/storage.go @@ -34,7 +34,7 @@ var ( _ op.ClientsStorage = &Storage{} ) -// storage implements the op.Storage interface +// Storage implements the op.Storage interface // typically you would implement this as a layer on top of your database // for simplicity this example keeps everything in-memory type Storage struct { @@ -1032,3 +1032,25 @@ func (s *Storage) DeleteClient(_ context.Context, clientID string) error { delete(s.clients, clientID) return nil } + +func (s *Storage) AuthorizeClientRegistration(ctx context.Context, clientID, initialAccessToken string, c *oidc.ClientRegistrationRequest) error { + if initialAccessToken != "verysecure" { + return errors.New("invalid initial access token") + } + return nil +} + +func (s *Storage) AuthorizeClientRead(ctx context.Context, clientID, registrationAccessToken string) error { + //TODO implement me + panic("implement me") +} + +func (s *Storage) AuthorizeClientUpdate(ctx context.Context, clientID, registrationAccessToken string) error { + //TODO implement me + panic("implement me") +} + +func (s *Storage) AuthorizeClientDelete(ctx context.Context, clientID, registrationAccessToken string) error { + //TODO implement me + panic("implement me") +} diff --git a/pkg/op/storage.go b/pkg/op/storage.go index a123c205..43ec7425 100644 --- a/pkg/op/storage.go +++ b/pkg/op/storage.go @@ -223,6 +223,35 @@ type ClientsStorage interface { // // [RFC7592]: https://www.rfc-editor.org/rfc/rfc7592.html#section-2.3 DeleteClient(ctx context.Context, clientID string) error + + // AuthorizeClientRegistration will check if a Client Registration Request ([RFC7591]) is authorized by parsing + // either: + // + // - an initial access token (OAuth 2.0 access token optionally issued by an authorization server to a developer + // or client and used to authorize calls to the client registration endpoint.), or + // - a software statement (A digitally signed or MACed JSON Web Token (JWT) [RFC7519] that asserts metadata + // values about the client software.) + // + // [RFC7591]: https://www.rfc-editor.org/rfc/rfc7591 + // [RFC7519]: https://www.rfc-editor.org/rfc/rfc7519 + AuthorizeClientRegistration(ctx context.Context, clientID, initialAccessToken string, c *oidc.ClientRegistrationRequest) error + + // AuthorizeClientRead will check if a Client Read Request ([RFC7592]) is authorized for + // [Protected Dynamic Client Registration]. + // + // [RFC7592]: https://www.rfc-editor.org/rfc/rfc7592 + // [Protected Dynamic Client Registration]: https://www.rfc-editor.org/rfc/rfc7591#appendix-A.1.2 + AuthorizeClientRead(ctx context.Context, clientID, registrationAccessToken string) error + + // AuthorizeClientUpdate will check if a Client Update Request ([RFC7592]) is authorized. + // + // [RFC7592]: https://www.rfc-editor.org/rfc/rfc7592 + AuthorizeClientUpdate(ctx context.Context, clientID, registrationAccessToken string) error + + // AuthorizeClientDelete will check if a Client Delete Request ([RFC7592]) is authorized. + // + // [RFC7592]: https://www.rfc-editor.org/rfc/rfc7592 + AuthorizeClientDelete(ctx context.Context, clientID, registrationAccessToken string) error } func assertClientStorage(s Storage) (ClientsStorage, error) { From 79fc28486bc939c75b89085418a6592821fedf28 Mon Sep 17 00:00:00 2001 From: mqf20 Date: Tue, 5 Aug 2025 08:53:50 +0800 Subject: [PATCH 18/44] WIP refactored handlers Signed-off-by: mqf20 --- pkg/op/dynamic_client_registration.go | 168 ++++++++++++++++++-------- pkg/op/op.go | 4 +- pkg/op/storage.go | 2 +- 3 files changed, 122 insertions(+), 52 deletions(-) diff --git a/pkg/op/dynamic_client_registration.go b/pkg/op/dynamic_client_registration.go index 08cffcb0..755ecf75 100644 --- a/pkg/op/dynamic_client_registration.go +++ b/pkg/op/dynamic_client_registration.go @@ -3,29 +3,35 @@ package op import ( "errors" "fmt" + "github.com/go-chi/chi/v5" "github.com/go-jose/go-jose/v4/json" httphelper "github.com/zitadel/oidc/v3/pkg/http" "github.com/zitadel/oidc/v3/pkg/oidc" "net/http" + "strings" ) -func RegistrationHandler(o OpenIDProvider) func(http.ResponseWriter, *http.Request) { +var ( + errMissingAuthorizationHeader = errors.New("missing authorization header") + errInvalidHeader = errors.New("invalid header") +) + +func getToken(r *http.Request) (string, error) { + auth := r.Header.Get("authorization") + if auth == "" { + return "", errMissingAuthorizationHeader + } + if !strings.HasPrefix(auth, oidc.PrefixBearer) { + return "", errInvalidHeader + } + return strings.TrimPrefix(auth, oidc.PrefixBearer), nil +} + +func clientReadHandler(o OpenIDProvider) func(http.ResponseWriter, *http.Request) { return func(w http.ResponseWriter, r *http.Request) { switch r.Method { - case http.MethodPost: - if err := ClientRegistration(w, r, o); err != nil { - RequestError(w, r, err, o.Logger()) - } case http.MethodGet: - if err := ClientRead(w, r, o); err != nil { - RequestError(w, r, err, o.Logger()) - } - case http.MethodPut: - if err := ClientUpdate(w, r, o); err != nil { - RequestError(w, r, err, o.Logger()) - } - case http.MethodDelete: - if err := ClientDelete(w, r, o); err != nil { + if err := clientRead(w, r, o); err != nil { RequestError(w, r, err, o.Logger()) } default: @@ -34,17 +40,17 @@ func RegistrationHandler(o OpenIDProvider) func(http.ResponseWriter, *http.Reque } } -// ClientRegistration handles [client registration requests] as part of the -// [OAuth 2.0 Dynamic Client Registration Protocol]. +// clientRead handles [client read requests] as part of the +// [OAuth 2.0 Dynamic Client Registration Management Protocol]. // -// [client registration requests]: https://www.rfc-editor.org/rfc/rfc7591#section-3.1 -// [OAuth 2.0 Dynamic Client Registration Protocol]: https://www.rfc-editor.org/rfc/rfc7591 -func ClientRegistration(w http.ResponseWriter, r *http.Request, o OpenIDProvider) error { - ctx, span := tracer.Start(r.Context(), "ClientRegistration") +// [client read requests]: https://www.rfc-editor.org/rfc/rfc7592.html#section-2.1 +// [OAuth 2.0 Dynamic Client Registration Management Protocol]: https://www.rfc-editor.org/rfc/rfc7592.html +func clientRead(w http.ResponseWriter, r *http.Request, o OpenIDProvider) error { + ctx, span := tracer.Start(r.Context(), "clientRead") r = r.WithContext(ctx) defer span.End() - req, err := ParseClientRegistrationRequest(r) + req, err := ParseClientReadRequest(r, o) if err != nil { // TODO(mqf20): be able to return the proper error codes? return err @@ -55,46 +61,71 @@ func ClientRegistration(w http.ResponseWriter, r *http.Request, o OpenIDProvider return errors.New("dynamic client registration unsupported") } - clientID, err := storage.RegisterClient(ctx, req) + registrationAccessToken, err := getToken(r) if err != nil { - // TODO(mqf20): be able to return the proper error codes? return err } - res, err := storage.ReadClient(ctx, clientID) + if err := storage.AuthorizeClientRead(ctx, req.ClientID, registrationAccessToken); err != nil { + return err + } + + res, err := storage.ReadClient(r.Context(), req.ClientID) if err != nil { // TODO(mqf20): be able to return the proper error codes? return err } - httphelper.MarshalJSONWithStatus(w, res, http.StatusCreated) + httphelper.MarshalJSON(w, res) return nil } -func ParseClientRegistrationRequest(r *http.Request) (*oidc.ClientRegistrationRequest, error) { - ctx, span := tracer.Start(r.Context(), "ParseClientRegistrationRequest") +func ParseClientReadRequest(r *http.Request, o OpenIDProvider) (*oidc.ClientReadRequest, error) { + ctx, span := tracer.Start(r.Context(), "ParseClientReadRequest") r = r.WithContext(ctx) defer span.End() - req := new(oidc.ClientRegistrationRequest) - if err := json.NewDecoder(r.Body).Decode(req); err != nil { - return nil, oidc.ErrInvalidRequest().WithDescription("cannot parse client registration request").WithParent(err) + req := new(oidc.ClientReadRequest) + if err := o.Decoder().Decode(req, r.Form); err != nil { + return nil, oidc.ErrInvalidRequest().WithDescription("cannot parse client read request").WithParent(err) } + req.ClientID = chi.URLParam(r, "client_id") return req, nil } -// ClientRead handles [client read requests] as part of the -// [OAuth 2.0 Dynamic Client Registration Management Protocol]. +func clientRegistrationUpdateDeleteHandler(o OpenIDProvider) func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + if err := clientRegistration(w, r, o); err != nil { + RequestError(w, r, err, o.Logger()) + } + case http.MethodPut: + if err := clientUpdate(w, r, o); err != nil { + RequestError(w, r, err, o.Logger()) + } + case http.MethodDelete: + if err := clientDelete(w, r, o); err != nil { + RequestError(w, r, err, o.Logger()) + } + default: + RequestError(w, r, fmt.Errorf("unsupported method: %s", r.Method), o.Logger()) + } + } +} + +// clientRegistration handles [client registration requests] as part of the +// [OAuth 2.0 Dynamic Client Registration Protocol]. // -// [client read requests]: https://www.rfc-editor.org/rfc/rfc7592.html#section-2.1 -// [OAuth 2.0 Dynamic Client Registration Management Protocol]: https://www.rfc-editor.org/rfc/rfc7592.html -func ClientRead(w http.ResponseWriter, r *http.Request, o OpenIDProvider) error { - ctx, span := tracer.Start(r.Context(), "ClientRead") +// [client registration requests]: https://www.rfc-editor.org/rfc/rfc7591#section-3.1 +// [OAuth 2.0 Dynamic Client Registration Protocol]: https://www.rfc-editor.org/rfc/rfc7591 +func clientRegistration(w http.ResponseWriter, r *http.Request, o OpenIDProvider) error { + ctx, span := tracer.Start(r.Context(), "clientRegistration") r = r.WithContext(ctx) defer span.End() - req, err := ParseClientReadRequest(r, o) + req, err := ParseClientRegistrationRequest(r) if err != nil { // TODO(mqf20): be able to return the proper error codes? return err @@ -105,36 +136,55 @@ func ClientRead(w http.ResponseWriter, r *http.Request, o OpenIDProvider) error return errors.New("dynamic client registration unsupported") } - res, err := storage.ReadClient(r.Context(), req.ClientID) + var initialAccessToken string + if auth := r.Header.Get("authorization"); auth == "" { + iat, err := getToken(r) + if err != nil && !errors.Is(err, errMissingAuthorizationHeader) { + return err + } + initialAccessToken = iat + } + + if err := storage.AuthorizeClientRegistration(ctx, initialAccessToken, req); err != nil { + return err + } + + clientID, err := storage.RegisterClient(ctx, req) if err != nil { // TODO(mqf20): be able to return the proper error codes? return err } - httphelper.MarshalJSON(w, res) + res, err := storage.ReadClient(ctx, clientID) + if err != nil { + // TODO(mqf20): be able to return the proper error codes? + return err + } + + httphelper.MarshalJSONWithStatus(w, res, http.StatusCreated) return nil } -func ParseClientReadRequest(r *http.Request, o OpenIDProvider) (*oidc.ClientReadRequest, error) { - ctx, span := tracer.Start(r.Context(), "ParseClientReadRequest") +func ParseClientRegistrationRequest(r *http.Request) (*oidc.ClientRegistrationRequest, error) { + ctx, span := tracer.Start(r.Context(), "ParseClientRegistrationRequest") r = r.WithContext(ctx) defer span.End() - req := new(oidc.ClientReadRequest) - if err := o.Decoder().Decode(req, r.Form); err != nil { - return nil, oidc.ErrInvalidRequest().WithDescription("cannot parse client read request").WithParent(err) + req := new(oidc.ClientRegistrationRequest) + if err := json.NewDecoder(r.Body).Decode(req); err != nil { + return nil, oidc.ErrInvalidRequest().WithDescription("cannot parse client registration request").WithParent(err) } return req, nil } -// ClientUpdate handles [client update requests] as part of the +// clientUpdate handles [client update requests] as part of the // [OAuth 2.0 Dynamic Client Registration Management Protocol]. // // [client update requests]: https://www.rfc-editor.org/rfc/rfc7592.html#section-2.2 // [OAuth 2.0 Dynamic Client Registration Management Protocol]: https://www.rfc-editor.org/rfc/rfc7592.html -func ClientUpdate(w http.ResponseWriter, r *http.Request, o OpenIDProvider) error { - ctx, span := tracer.Start(r.Context(), "ClientUpdate") +func clientUpdate(w http.ResponseWriter, r *http.Request, o OpenIDProvider) error { + ctx, span := tracer.Start(r.Context(), "clientUpdate") r = r.WithContext(ctx) defer span.End() @@ -149,6 +199,15 @@ func ClientUpdate(w http.ResponseWriter, r *http.Request, o OpenIDProvider) erro return errors.New("dynamic client registration unsupported") } + registrationAccessToken, err := getToken(r) + if err != nil { + return err + } + + if err := storage.AuthorizeClientUpdate(ctx, req.ClientID, registrationAccessToken); err != nil { + return err + } + if err := storage.UpdateClient(ctx, req); err != nil { // TODO(mqf20): be able to return the proper error codes? return err @@ -177,13 +236,13 @@ func ParseClientUpdateRequest(r *http.Request, o OpenIDProvider) (*oidc.ClientUp return req, nil } -// ClientDelete handles [client delete requests] as part of the +// clientDelete handles [client delete requests] as part of the // [OAuth 2.0 Dynamic Client Registration Management Protocol]. // // [client delete requests]: https://www.rfc-editor.org/rfc/rfc7592.html#section-2.3 // [OAuth 2.0 Dynamic Client Registration Management Protocol]: https://www.rfc-editor.org/rfc/rfc7592.html -func ClientDelete(w http.ResponseWriter, r *http.Request, o OpenIDProvider) error { - ctx, span := tracer.Start(r.Context(), "ClientDelete") +func clientDelete(w http.ResponseWriter, r *http.Request, o OpenIDProvider) error { + ctx, span := tracer.Start(r.Context(), "clientDelete") r = r.WithContext(ctx) defer span.End() @@ -198,6 +257,15 @@ func ClientDelete(w http.ResponseWriter, r *http.Request, o OpenIDProvider) erro return errors.New("dynamic client registration unsupported") } + registrationAccessToken, err := getToken(r) + if err != nil { + return err + } + + if err := storage.AuthorizeClientDelete(ctx, req.ClientID, registrationAccessToken); err != nil { + return err + } + if err := storage.DeleteClient(ctx, req.ClientID); err != nil { // TODO(mqf20): be able to return the proper error codes? return err diff --git a/pkg/op/op.go b/pkg/op/op.go index fa86d407..a15fac21 100644 --- a/pkg/op/op.go +++ b/pkg/op/op.go @@ -5,6 +5,7 @@ import ( "fmt" "log/slog" "net/http" + "path" "time" "github.com/go-chi/chi/v5" @@ -145,7 +146,8 @@ func CreateRouter(o OpenIDProvider, interceptors ...HttpInterceptor) chi.Router router.HandleFunc(o.EndSessionEndpoint().Relative(), endSessionHandler(o)) router.HandleFunc(o.KeysEndpoint().Relative(), keysHandler(o.Storage())) router.HandleFunc(o.DeviceAuthorizationEndpoint().Relative(), DeviceAuthorizationHandler(o)) - router.HandleFunc(o.RegistrationEndpoint().Relative(), RegistrationHandler(o)) + router.HandleFunc(o.RegistrationEndpoint().Relative(), clientRegistrationUpdateDeleteHandler(o)) + router.HandleFunc(path.Join(o.RegistrationEndpoint().Relative(), "{client_id}"), clientReadHandler(o)) return router } diff --git a/pkg/op/storage.go b/pkg/op/storage.go index 06aa6d73..f036e46e 100644 --- a/pkg/op/storage.go +++ b/pkg/op/storage.go @@ -240,7 +240,7 @@ type ClientsStorage interface { // // [RFC7591]: https://www.rfc-editor.org/rfc/rfc7591 // [RFC7519]: https://www.rfc-editor.org/rfc/rfc7519 - AuthorizeClientRegistration(ctx context.Context, clientID, initialAccessToken string, c *oidc.ClientRegistrationRequest) error + AuthorizeClientRegistration(ctx context.Context, initialAccessToken string, c *oidc.ClientRegistrationRequest) error // AuthorizeClientRead will check if a Client Read Request ([RFC7592]) is authorized for // [Protected Dynamic Client Registration]. From 1fb98cf651e3a8fca03991bd85b205de62638afb Mon Sep 17 00:00:00 2001 From: mqf20 Date: Tue, 5 Aug 2025 21:43:28 +0800 Subject: [PATCH 19/44] WIP improved interface Signed-off-by: mqf20 --- pkg/op/dynamic_client_registration.go | 25 +++++++------------------ pkg/op/storage.go | 9 ++++++--- 2 files changed, 13 insertions(+), 21 deletions(-) diff --git a/pkg/op/dynamic_client_registration.go b/pkg/op/dynamic_client_registration.go index 755ecf75..db4ec43e 100644 --- a/pkg/op/dynamic_client_registration.go +++ b/pkg/op/dynamic_client_registration.go @@ -16,7 +16,7 @@ var ( errInvalidHeader = errors.New("invalid header") ) -func getToken(r *http.Request) (string, error) { +func getBearerToken(r *http.Request) (string, error) { auth := r.Header.Get("authorization") if auth == "" { return "", errMissingAuthorizationHeader @@ -61,7 +61,7 @@ func clientRead(w http.ResponseWriter, r *http.Request, o OpenIDProvider) error return errors.New("dynamic client registration unsupported") } - registrationAccessToken, err := getToken(r) + registrationAccessToken, err := getBearerToken(r) if err != nil { return err } @@ -138,7 +138,7 @@ func clientRegistration(w http.ResponseWriter, r *http.Request, o OpenIDProvider var initialAccessToken string if auth := r.Header.Get("authorization"); auth == "" { - iat, err := getToken(r) + iat, err := getBearerToken(r) if err != nil && !errors.Is(err, errMissingAuthorizationHeader) { return err } @@ -149,13 +149,7 @@ func clientRegistration(w http.ResponseWriter, r *http.Request, o OpenIDProvider return err } - clientID, err := storage.RegisterClient(ctx, req) - if err != nil { - // TODO(mqf20): be able to return the proper error codes? - return err - } - - res, err := storage.ReadClient(ctx, clientID) + res, err := storage.RegisterClient(ctx, req) if err != nil { // TODO(mqf20): be able to return the proper error codes? return err @@ -199,7 +193,7 @@ func clientUpdate(w http.ResponseWriter, r *http.Request, o OpenIDProvider) erro return errors.New("dynamic client registration unsupported") } - registrationAccessToken, err := getToken(r) + registrationAccessToken, err := getBearerToken(r) if err != nil { return err } @@ -208,12 +202,7 @@ func clientUpdate(w http.ResponseWriter, r *http.Request, o OpenIDProvider) erro return err } - if err := storage.UpdateClient(ctx, req); err != nil { - // TODO(mqf20): be able to return the proper error codes? - return err - } - - res, err := storage.ReadClient(ctx, req.ClientID) + res, err := storage.UpdateClient(ctx, req) if err != nil { // TODO(mqf20): be able to return the proper error codes? return err @@ -257,7 +246,7 @@ func clientDelete(w http.ResponseWriter, r *http.Request, o OpenIDProvider) erro return errors.New("dynamic client registration unsupported") } - registrationAccessToken, err := getToken(r) + registrationAccessToken, err := getBearerToken(r) if err != nil { return err } diff --git a/pkg/op/storage.go b/pkg/op/storage.go index f036e46e..daacf22e 100644 --- a/pkg/op/storage.go +++ b/pkg/op/storage.go @@ -216,15 +216,18 @@ type ClientsStorage interface { // RegisterClient handles the Client Registration Request according to [RFC7591]. // // [RFC7591]: https://www.rfc-editor.org/rfc/rfc7591#section-3.1 - RegisterClient(ctx context.Context, c *oidc.ClientRegistrationRequest) (clientID string, err error) + RegisterClient(ctx context.Context, c *oidc.ClientRegistrationRequest) (*oidc.ClientRegistrationResponse, error) + // ReadClient handles the Client Read Request according to [RFC7592]. // // [RFC7592]: https://www.rfc-editor.org/rfc/rfc7592.html#section-2.1 - ReadClient(ctx context.Context, clientID string) (*oidc.ClientInformationResponse, error) + ReadClient(ctx context.Context, clientID string) (*oidc.ClientReadResponse, error) + // UpdateClient handles the Client Update Request according to [RFC7592]. // // [RFC7592]: https://www.rfc-editor.org/rfc/rfc7592.html#section-2.2 - UpdateClient(ctx context.Context, c *oidc.ClientUpdateRequest) error + UpdateClient(ctx context.Context, c *oidc.ClientUpdateRequest) (*oidc.ClientInformationResponse, error) + // DeleteClient handles the Client Delete Request according to [RFC7592]. // // [RFC7592]: https://www.rfc-editor.org/rfc/rfc7592.html#section-2.3 From 843cc6bcd5769dd8fcca8c33cb23c2d304b10562 Mon Sep 17 00:00:00 2001 From: mqf20 Date: Tue, 5 Aug 2025 21:43:40 +0800 Subject: [PATCH 20/44] WIP fixed errors in examples Signed-off-by: mqf20 --- example/server/storage/storage.go | 178 ++++++++++++++++++++++++------ 1 file changed, 143 insertions(+), 35 deletions(-) diff --git a/example/server/storage/storage.go b/example/server/storage/storage.go index 2f88c3f8..949075a4 100644 --- a/example/server/storage/storage.go +++ b/example/server/storage/storage.go @@ -933,7 +933,7 @@ func (s *Storage) ClientCredentialsTokenRequest(ctx context.Context, clientID st }, nil } -func (s *Storage) RegisterClient(_ context.Context, c *oidc.ClientRegistrationRequest) (string, error) { +func (s *Storage) RegisterClient(_ context.Context, c *oidc.ClientRegistrationRequest) (*oidc.ClientRegistrationResponse, error) { s.lock.Lock() defer s.lock.Unlock() client := Client{ @@ -954,53 +954,119 @@ func (s *Storage) RegisterClient(_ context.Context, c *oidc.ClientRegistrationRe } s.clients[client.id] = &client - return client.id, nil + return &oidc.ClientRegistrationResponse{ + ClientInformationResponse: oidc.ClientInformationResponse{ + ClientMetadata: oidc.ClientMetadata{ + RedirectURIs: nil, + TokenEndpointAuthMethod: "", + GrantTypes: nil, + ResponseTypes: nil, + ClientName: nil, + ClientURI: nil, + LogoURI: nil, + Scope: "", + Contacts: nil, + TOSURI: nil, + PolicyURI: nil, + JWKSURI: "", + JWKS: jose.JSONWebKeySet{}, + SoftwareID: "", + SoftwareVersion: "", + ApplicationType: "", + SectorIdentifierURI: "", + SubjectType: "", + IDTokenSignedResponseAlg: "", + IDTokenEncryptedResponseAlg: "", + IDTokenEncryptedResponseEnc: "", + UserinfoSignedResponseAlg: "", + UserinfoEncryptedResponseAlg: "", + UserinfoEncryptedResponseEnc: "", + RequestObjectSigningAlg: "", + RequestObjectEncryptionAlg: "", + RequestObjectEncryptionEnc: "", + TokenEndpointAuthSigningAlg: "", + DefaultMaxAge: 0, + RequireAuthTime: false, + DefaultACRValues: nil, + InitiateLoginURI: "", + RequestURIs: nil, + PostLogoutRedirectURIs: nil, + ExtraParameters: nil, + }, + ClientID: "", + ClientSecret: "", + ClientIDIssuedAt: 0, + ClientSecretExpiresAt: 0, + }, + RegistrationAccessToken: "", + RegistrationClientURI: "", + }, nil } -func (s *Storage) ReadClient(_ context.Context, clientID string) (*oidc.ClientInformationResponse, error) { +func (s *Storage) ReadClient(_ context.Context, clientID string) (*oidc.ClientReadResponse, error) { s.lock.Lock() defer s.lock.Unlock() client, ok := s.clients[clientID] if !ok { return nil, errors.New("client not found") } - return &oidc.ClientInformationResponse{ - ClientID: client.id, - ClientSecret: client.secret, - ClientIDIssuedAt: 0, - ClientSecretExpiresAt: 0, - //ClientIDIssuedAt: 0, - //ClientSecretExpiresAt: 0, - RedirectURIs: client.RedirectURIs(), - TokenEndpointAuthMethod: client.AuthMethod(), - GrantTypes: client.GrantTypes(), - ResponseTypes: client.ResponseTypes(), - ClientName: nil, - ClientURI: nil, - LogoURI: nil, - //ClientName: "", - //ClientURI: "", - //LogoURI: "", - Scope: "", - Contacts: nil, - TOSURI: nil, - PolicyURI: nil, - JWKSURI: "", - JWKS: jose.JSONWebKeySet{}, - SoftwareID: "", - SoftwareVersion: "", - RegistrationAccessToken: "", - RegistrationClientURI: "", - ExtraParameters: nil, + return &oidc.ClientReadResponse{ + ClientRegistrationResponse: oidc.ClientRegistrationResponse{ + ClientInformationResponse: oidc.ClientInformationResponse{ + ClientMetadata: oidc.ClientMetadata{ + RedirectURIs: client.redirectURIs, + TokenEndpointAuthMethod: client.authMethod, + GrantTypes: nil, + ResponseTypes: nil, + ClientName: nil, + ClientURI: nil, + LogoURI: nil, + Scope: "", + Contacts: nil, + TOSURI: nil, + PolicyURI: nil, + JWKSURI: "", + JWKS: jose.JSONWebKeySet{}, + SoftwareID: "", + SoftwareVersion: "", + ApplicationType: "", + SectorIdentifierURI: "", + SubjectType: "", + IDTokenSignedResponseAlg: "", + IDTokenEncryptedResponseAlg: "", + IDTokenEncryptedResponseEnc: "", + UserinfoSignedResponseAlg: "", + UserinfoEncryptedResponseAlg: "", + UserinfoEncryptedResponseEnc: "", + RequestObjectSigningAlg: "", + RequestObjectEncryptionAlg: "", + RequestObjectEncryptionEnc: "", + TokenEndpointAuthSigningAlg: "", + DefaultMaxAge: 0, + RequireAuthTime: false, + DefaultACRValues: nil, + InitiateLoginURI: "", + RequestURIs: nil, + PostLogoutRedirectURIs: nil, + ExtraParameters: nil, + }, + ClientID: "", + ClientSecret: "", + ClientIDIssuedAt: 0, + ClientSecretExpiresAt: 0, + }, + RegistrationAccessToken: "", + RegistrationClientURI: "", + }, }, nil } -func (s *Storage) UpdateClient(_ context.Context, c *oidc.ClientUpdateRequest) error { +func (s *Storage) UpdateClient(_ context.Context, c *oidc.ClientUpdateRequest) (*oidc.ClientInformationResponse, error) { s.lock.Lock() defer s.lock.Unlock() client, ok := s.clients[c.ClientID] if !ok { - return errors.New("client not found") + return nil, errors.New("client not found") } //client.id = "" client.secret = c.ClientSecret @@ -1022,7 +1088,49 @@ func (s *Storage) UpdateClient(_ context.Context, c *oidc.ClientUpdateRequest) e //client.??? = c.SoftwareID //client.??? = c.SoftwareVersion - return nil + return &oidc.ClientInformationResponse{ + ClientMetadata: oidc.ClientMetadata{ + RedirectURIs: nil, + TokenEndpointAuthMethod: "", + GrantTypes: nil, + ResponseTypes: nil, + ClientName: nil, + ClientURI: nil, + LogoURI: nil, + Scope: "", + Contacts: nil, + TOSURI: nil, + PolicyURI: nil, + JWKSURI: "", + JWKS: jose.JSONWebKeySet{}, + SoftwareID: "", + SoftwareVersion: "", + ApplicationType: "", + SectorIdentifierURI: "", + SubjectType: "", + IDTokenSignedResponseAlg: "", + IDTokenEncryptedResponseAlg: "", + IDTokenEncryptedResponseEnc: "", + UserinfoSignedResponseAlg: "", + UserinfoEncryptedResponseAlg: "", + UserinfoEncryptedResponseEnc: "", + RequestObjectSigningAlg: "", + RequestObjectEncryptionAlg: "", + RequestObjectEncryptionEnc: "", + TokenEndpointAuthSigningAlg: "", + DefaultMaxAge: 0, + RequireAuthTime: false, + DefaultACRValues: nil, + InitiateLoginURI: "", + RequestURIs: nil, + PostLogoutRedirectURIs: nil, + ExtraParameters: nil, + }, + //ClientID: "", + //ClientSecret: "", + //ClientIDIssuedAt: 0, + //ClientSecretExpiresAt: 0, + }, nil } func (s *Storage) DeleteClient(_ context.Context, clientID string) error { @@ -1033,7 +1141,7 @@ func (s *Storage) DeleteClient(_ context.Context, clientID string) error { return nil } -func (s *Storage) AuthorizeClientRegistration(ctx context.Context, clientID, initialAccessToken string, c *oidc.ClientRegistrationRequest) error { +func (s *Storage) AuthorizeClientRegistration(ctx context.Context, initialAccessToken string, c *oidc.ClientRegistrationRequest) error { if initialAccessToken != "verysecure" { return errors.New("invalid initial access token") } From 508facd3a6e7a54c9a82f91a35b75bb4b677ce1b Mon Sep 17 00:00:00 2001 From: mqf20 Date: Tue, 5 Aug 2025 21:43:49 +0800 Subject: [PATCH 21/44] WIP added docs Signed-off-by: mqf20 --- pkg/oidc/dynamic_client_registration.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/oidc/dynamic_client_registration.go b/pkg/oidc/dynamic_client_registration.go index d71ec4c2..60fff25d 100644 --- a/pkg/oidc/dynamic_client_registration.go +++ b/pkg/oidc/dynamic_client_registration.go @@ -1178,6 +1178,9 @@ type ClientInformationErrorResponseErrorCode string // ClientReadResponse implements // https://openid.net/specs/openid-connect-registration-1_0.html#ReadResponse // 4.3. Client Read Response. +// +// The Authorization Server need not include the registration_access_token or registration_client_uri value in this +// response unless they have been updated. type ClientReadResponse struct { ClientRegistrationResponse } From 9fd41465c98da2d6fc1282f171409775507d7071 Mon Sep 17 00:00:00 2001 From: mqf20 Date: Tue, 5 Aug 2025 21:53:23 +0800 Subject: [PATCH 22/44] WIP refactored Signed-off-by: mqf20 --- pkg/op/dynamic_client_registration.go | 33 ++++++++++++++------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/pkg/op/dynamic_client_registration.go b/pkg/op/dynamic_client_registration.go index db4ec43e..ef009733 100644 --- a/pkg/op/dynamic_client_registration.go +++ b/pkg/op/dynamic_client_registration.go @@ -50,15 +50,15 @@ func clientRead(w http.ResponseWriter, r *http.Request, o OpenIDProvider) error r = r.WithContext(ctx) defer span.End() - req, err := ParseClientReadRequest(r, o) + storage, err := assertClientStorage(o.Storage()) if err != nil { - // TODO(mqf20): be able to return the proper error codes? return err } - storage, err := assertClientStorage(o.Storage()) + req, err := ParseClientReadRequest(r, o) if err != nil { - return errors.New("dynamic client registration unsupported") + // TODO(mqf20): be able to return the proper error codes? + return err } registrationAccessToken, err := getBearerToken(r) @@ -125,21 +125,22 @@ func clientRegistration(w http.ResponseWriter, r *http.Request, o OpenIDProvider r = r.WithContext(ctx) defer span.End() - req, err := ParseClientRegistrationRequest(r) + storage, err := assertClientStorage(o.Storage()) if err != nil { - // TODO(mqf20): be able to return the proper error codes? return err } - storage, err := assertClientStorage(o.Storage()) + req, err := ParseClientRegistrationRequest(r) if err != nil { - return errors.New("dynamic client registration unsupported") + // TODO(mqf20): be able to return the proper error codes? + return err } var initialAccessToken string if auth := r.Header.Get("authorization"); auth == "" { iat, err := getBearerToken(r) if err != nil && !errors.Is(err, errMissingAuthorizationHeader) { + // allow for missing authorization header, in case the software statement is used for authentication return err } initialAccessToken = iat @@ -182,15 +183,15 @@ func clientUpdate(w http.ResponseWriter, r *http.Request, o OpenIDProvider) erro r = r.WithContext(ctx) defer span.End() - req, err := ParseClientUpdateRequest(r, o) + storage, err := assertClientStorage(o.Storage()) if err != nil { - // TODO(mqf20): be able to return the proper error codes? return err } - storage, err := assertClientStorage(o.Storage()) + req, err := ParseClientUpdateRequest(r, o) if err != nil { - return errors.New("dynamic client registration unsupported") + // TODO(mqf20): be able to return the proper error codes? + return err } registrationAccessToken, err := getBearerToken(r) @@ -235,15 +236,15 @@ func clientDelete(w http.ResponseWriter, r *http.Request, o OpenIDProvider) erro r = r.WithContext(ctx) defer span.End() - req, err := ParseClientDeleteRequest(r, o) + storage, err := assertClientStorage(o.Storage()) if err != nil { - // TODO(mqf20): be able to return the proper error codes? return err } - storage, err := assertClientStorage(o.Storage()) + req, err := ParseClientDeleteRequest(r, o) if err != nil { - return errors.New("dynamic client registration unsupported") + // TODO(mqf20): be able to return the proper error codes? + return err } registrationAccessToken, err := getBearerToken(r) From 47cd6a275c5b7e873f1d869330f2d40ac8ddf648 Mon Sep 17 00:00:00 2001 From: mqf20 Date: Tue, 5 Aug 2025 22:02:34 +0800 Subject: [PATCH 23/44] WIP added docs Signed-off-by: mqf20 --- pkg/oidc/dynamic_client_registration.go | 36 +++++++++++++++++++------ 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/pkg/oidc/dynamic_client_registration.go b/pkg/oidc/dynamic_client_registration.go index 60fff25d..a5d3695f 100644 --- a/pkg/oidc/dynamic_client_registration.go +++ b/pkg/oidc/dynamic_client_registration.go @@ -1158,19 +1158,39 @@ func (c ClientRegistrationResponse) MarshalJSON() ([]byte, error) { // ClientInformationErrorResponse implements // https://www.rfc-editor.org/rfc/rfc7591#section-3.2.1, -// 3.2.1. Client Information Response and +// 3.2.1. Client Information Response, // https://www.rfc-editor.org/rfc/rfc7592.html#section-3 -// 3. Client Information Response. +// 3. Client Information Response, and +// https://openid.net/specs/openid-connect-registration-1_0.html#RegistrationError +// 3.3. Client Registration Error Response. type ClientInformationErrorResponse struct { - Error ClientInformationErrorResponseErrorCode `json:"error"` // Single ASCII error code string. - ErrorDescription string `json:"error_description,omitempty"` // Human-readable ASCII text description of the error used for debugging. + // Error is a single ASCII error code string. + // + // REQUIRED. + Error ClientInformationErrorResponseErrorCode `json:"error"` + + // ErrorDescription is a human-readable ASCII text description of the error used for debugging. + // + // OPTIONAL. + ErrorDescription string `json:"error_description,omitempty"` } const ( - ClientInformationErrorResponseErrorCodeInvalidRedirectURI ClientInformationErrorResponseErrorCode = "invalid_redirect_uri" // The value of one or more redirection URIs is invalid. - ClientInformationErrorResponseErrorCodeInvalidClientMetadata ClientInformationErrorResponseErrorCode = "invalid_client_metadata" // The value of one of the client metadata fields is invalid and the server has rejected this request. - ClientInformationErrorResponseErrorCodeInvalidSoftwareStatement ClientInformationErrorResponseErrorCode = "invalid_software_statement" // The software statement presented is invalid. - ClientInformationErrorResponseErrorCodeUnapprovedSoftwareStatement ClientInformationErrorResponseErrorCode = "unapproved_software_statement" // The software statement presented is not approved for use by this authorization server. + // ClientInformationErrorResponseErrorCodeInvalidRedirectURI indicates that + // the value of one or more redirection URIs is invalid. + ClientInformationErrorResponseErrorCodeInvalidRedirectURI ClientInformationErrorResponseErrorCode = "invalid_redirect_uri" + + // ClientInformationErrorResponseErrorCodeInvalidClientMetadata indicates that + // the value of one of the client metadata fields is invalid and the server has rejected this request. + ClientInformationErrorResponseErrorCodeInvalidClientMetadata ClientInformationErrorResponseErrorCode = "invalid_client_metadata" + + // ClientInformationErrorResponseErrorCodeInvalidSoftwareStatement indicates that + // the software statement presented is invalid. + ClientInformationErrorResponseErrorCodeInvalidSoftwareStatement ClientInformationErrorResponseErrorCode = "invalid_software_statement" + + // ClientInformationErrorResponseErrorCodeUnapprovedSoftwareStatement indicates that + // the software statement presented is not approved for use by this authorization server. + ClientInformationErrorResponseErrorCodeUnapprovedSoftwareStatement ClientInformationErrorResponseErrorCode = "unapproved_software_statement" ) type ClientInformationErrorResponseErrorCode string From f9488a388ba3e2b840213b96082672f2a082b6f2 Mon Sep 17 00:00:00 2001 From: mqf20 Date: Tue, 5 Aug 2025 22:08:51 +0800 Subject: [PATCH 24/44] WIP added docs Signed-off-by: mqf20 --- pkg/op/dynamic_client_registration.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/pkg/op/dynamic_client_registration.go b/pkg/op/dynamic_client_registration.go index ef009733..be778a53 100644 --- a/pkg/op/dynamic_client_registration.go +++ b/pkg/op/dynamic_client_registration.go @@ -16,6 +16,16 @@ var ( errInvalidHeader = errors.New("invalid header") ) +// getBearerToken extracts a bearer token from a HTTP request. +// +// For example, getBearerToken returns +// `this.is.an.access.token.value.ffx83` +// from the request below: +// +// GET /connect/register?client_id=s6BhdRkqt3 HTTP/1.1 +// Accept: application/json +// Host: server.example.com +// Authorization: Bearer this.is.an.access.token.value.ffx83 func getBearerToken(r *http.Request) (string, error) { auth := r.Header.Get("authorization") if auth == "" { @@ -156,6 +166,11 @@ func clientRegistration(w http.ResponseWriter, r *http.Request, o OpenIDProvider return err } + // Upon a successful registration request, the authorization server + // returns a client identifier for the client. The server responds with + // an HTTP 201 Created status code and a body of type "application/json" + // containing a Client Information Response. + httphelper.MarshalJSONWithStatus(w, res, http.StatusCreated) return nil } From 435cfe70d1dbbd06c08a919c55284e5516395695 Mon Sep 17 00:00:00 2001 From: mqf20 Date: Tue, 5 Aug 2025 22:19:13 +0800 Subject: [PATCH 25/44] WIP improved error checking for ClientMetadata UnmarshalJSON Signed-off-by: mqf20 --- pkg/oidc/dynamic_client_registration.go | 27 +++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/pkg/oidc/dynamic_client_registration.go b/pkg/oidc/dynamic_client_registration.go index a5d3695f..c9d28ead 100644 --- a/pkg/oidc/dynamic_client_registration.go +++ b/pkg/oidc/dynamic_client_registration.go @@ -2,6 +2,7 @@ package oidc import ( "encoding/json" + "errors" "fmt" "github.com/go-jose/go-jose/v4" "strings" @@ -746,6 +747,32 @@ func (c *ClientMetadata) UnmarshalJSON(data []byte) error { } } + // Set default values + + if c.TokenEndpointAuthMethod == "" { + // If unspecified or omitted, + // the default is "client_secret_basic", denoting the HTTP Basic + // authentication scheme as specified in [Section 2.3.1] of OAuth 2.0. + // + // [Section 2.3.1]: https://www.rfc-editor.org/rfc/rfc7591#section-2.3.1 + c.TokenEndpointAuthMethod = AuthMethodBasic + } + + if len(c.GrantTypes) == 0 { + // If omitted, the default behavior is that the client will use only the "authorization_code" Grant Type. + c.GrantTypes = []GrantType{GrantTypeCode} + } + + if len(c.ResponseTypes) == 0 { + // If omitted, the default is that the client will use only the "code" response type. + c.ResponseTypes = []ResponseType{ResponseTypeCode} + } + + if c.JWKSURI != "" && len(c.JWKS.Keys) > 0 { + // The "jwks_uri" and "jwks" parameters MUST NOT both be present in the same request or response. + return errors.New("jwks_uri and jwks cannot both be present") + } + return nil } From e7c521c906c1e2b8a3a2ba83ab4cdcdc59cd7427 Mon Sep 17 00:00:00 2001 From: mqf20 Date: Tue, 5 Aug 2025 22:27:43 +0800 Subject: [PATCH 26/44] WIP improved error checking for ClientMetadata MarshalJSON Signed-off-by: mqf20 --- pkg/oidc/dynamic_client_registration.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/pkg/oidc/dynamic_client_registration.go b/pkg/oidc/dynamic_client_registration.go index c9d28ead..688e78b5 100644 --- a/pkg/oidc/dynamic_client_registration.go +++ b/pkg/oidc/dynamic_client_registration.go @@ -946,10 +946,22 @@ func (c ClientMetadata) MarshalJSON() ([]byte, error) { } // Add extra parameters + for key, value := range c.ExtraParameters { res[key] = value } + // Sanity checks + + if c.JWKSURI != "" && len(c.JWKS.Keys) > 0 { + // The "jwks_uri" and "jwks" parameters MUST NOT both be present in the same request or response. + return nil, errors.New("jwks_uri and jwks cannot both be present") + } + if c.JWKSURI != "" { + // Force jwks to be omitted if jwks_uri is set + c.JWKS.Keys = nil + } + return json.Marshal(res) } From d31d43f4e2c10ab7264ce34ed2f9e3bf73fc4721 Mon Sep 17 00:00:00 2001 From: mqf20 Date: Tue, 5 Aug 2025 22:30:47 +0800 Subject: [PATCH 27/44] WIP improved error checking for ClientMetadata MarshalJSON Signed-off-by: mqf20 --- pkg/oidc/dynamic_client_registration.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/pkg/oidc/dynamic_client_registration.go b/pkg/oidc/dynamic_client_registration.go index 688e78b5..047d84c0 100644 --- a/pkg/oidc/dynamic_client_registration.go +++ b/pkg/oidc/dynamic_client_registration.go @@ -957,10 +957,6 @@ func (c ClientMetadata) MarshalJSON() ([]byte, error) { // The "jwks_uri" and "jwks" parameters MUST NOT both be present in the same request or response. return nil, errors.New("jwks_uri and jwks cannot both be present") } - if c.JWKSURI != "" { - // Force jwks to be omitted if jwks_uri is set - c.JWKS.Keys = nil - } return json.Marshal(res) } From 98ebfbc1806fcb007729d4de2423ae6cecc87253 Mon Sep 17 00:00:00 2001 From: mqf20 Date: Fri, 8 Aug 2025 23:10:37 +0800 Subject: [PATCH 28/44] WIP added return codes Signed-off-by: mqf20 --- pkg/op/dynamic_client_registration.go | 247 +++++++++++++++++++++----- pkg/op/storage.go | 70 ++++++++ 2 files changed, 269 insertions(+), 48 deletions(-) diff --git a/pkg/op/dynamic_client_registration.go b/pkg/op/dynamic_client_registration.go index be778a53..d8b92501 100644 --- a/pkg/op/dynamic_client_registration.go +++ b/pkg/op/dynamic_client_registration.go @@ -7,6 +7,7 @@ import ( "github.com/go-jose/go-jose/v4/json" httphelper "github.com/zitadel/oidc/v3/pkg/http" "github.com/zitadel/oidc/v3/pkg/oidc" + "log/slog" "net/http" "strings" ) @@ -37,13 +38,16 @@ func getBearerToken(r *http.Request) (string, error) { return strings.TrimPrefix(auth, oidc.PrefixBearer), nil } +func clientRequestError(w http.ResponseWriter, r *http.Request, lvl slog.Level, errResp *oidc.ClientInformationErrorResponse, logger *slog.Logger, status int) { + logger.Log(r.Context(), lvl, "request error", "oidc_error", errResp) + httphelper.MarshalJSONWithStatus(w, errResp, status) +} + func clientReadHandler(o OpenIDProvider) func(http.ResponseWriter, *http.Request) { return func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: - if err := clientRead(w, r, o); err != nil { - RequestError(w, r, err, o.Logger()) - } + clientRead(w, r, o) default: RequestError(w, r, fmt.Errorf("unsupported method: %s", r.Method), o.Logger()) } @@ -55,39 +59,55 @@ func clientReadHandler(o OpenIDProvider) func(http.ResponseWriter, *http.Request // // [client read requests]: https://www.rfc-editor.org/rfc/rfc7592.html#section-2.1 // [OAuth 2.0 Dynamic Client Registration Management Protocol]: https://www.rfc-editor.org/rfc/rfc7592.html -func clientRead(w http.ResponseWriter, r *http.Request, o OpenIDProvider) error { +func clientRead(w http.ResponseWriter, r *http.Request, o OpenIDProvider) { ctx, span := tracer.Start(r.Context(), "clientRead") r = r.WithContext(ctx) defer span.End() storage, err := assertClientStorage(o.Storage()) if err != nil { - return err + http.Error(w, err.Error(), http.StatusBadRequest) + return } req, err := ParseClientReadRequest(r, o) if err != nil { - // TODO(mqf20): be able to return the proper error codes? - return err + http.Error(w, err.Error(), http.StatusBadRequest) + return } registrationAccessToken, err := getBearerToken(r) if err != nil { - return err + http.Error(w, err.Error(), http.StatusBadRequest) + return } if err := storage.AuthorizeClientRead(ctx, req.ClientID, registrationAccessToken); err != nil { - return err + if errors.Is(err, ErrInvalidClient) || errors.Is(err, ErrInvalidRegistrationAccessToken) { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + if errors.Is(err, ErrClientNoPermission) { + http.Error(w, err.Error(), http.StatusForbidden) + return + } + http.Error(w, err.Error(), http.StatusBadRequest) + return } res, err := storage.ReadClient(r.Context(), req.ClientID) if err != nil { - // TODO(mqf20): be able to return the proper error codes? - return err + if errors.Is(err, ErrInvalidClient) { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + o.Logger().Log(r.Context(), slog.LevelError, "read client error", "error", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return } httphelper.MarshalJSON(w, res) - return nil + return } func ParseClientReadRequest(r *http.Request, o OpenIDProvider) (*oidc.ClientReadRequest, error) { @@ -108,17 +128,11 @@ func clientRegistrationUpdateDeleteHandler(o OpenIDProvider) func(http.ResponseW return func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodPost: - if err := clientRegistration(w, r, o); err != nil { - RequestError(w, r, err, o.Logger()) - } + clientRegistration(w, r, o) case http.MethodPut: - if err := clientUpdate(w, r, o); err != nil { - RequestError(w, r, err, o.Logger()) - } + clientUpdate(w, r, o) case http.MethodDelete: - if err := clientDelete(w, r, o); err != nil { - RequestError(w, r, err, o.Logger()) - } + clientDelete(w, r, o) default: RequestError(w, r, fmt.Errorf("unsupported method: %s", r.Method), o.Logger()) } @@ -130,20 +144,21 @@ func clientRegistrationUpdateDeleteHandler(o OpenIDProvider) func(http.ResponseW // // [client registration requests]: https://www.rfc-editor.org/rfc/rfc7591#section-3.1 // [OAuth 2.0 Dynamic Client Registration Protocol]: https://www.rfc-editor.org/rfc/rfc7591 -func clientRegistration(w http.ResponseWriter, r *http.Request, o OpenIDProvider) error { +func clientRegistration(w http.ResponseWriter, r *http.Request, o OpenIDProvider) { ctx, span := tracer.Start(r.Context(), "clientRegistration") r = r.WithContext(ctx) defer span.End() storage, err := assertClientStorage(o.Storage()) if err != nil { - return err + http.Error(w, err.Error(), http.StatusBadRequest) + return } req, err := ParseClientRegistrationRequest(r) if err != nil { - // TODO(mqf20): be able to return the proper error codes? - return err + http.Error(w, err.Error(), http.StatusBadRequest) + return } var initialAccessToken string @@ -151,19 +166,83 @@ func clientRegistration(w http.ResponseWriter, r *http.Request, o OpenIDProvider iat, err := getBearerToken(r) if err != nil && !errors.Is(err, errMissingAuthorizationHeader) { // allow for missing authorization header, in case the software statement is used for authentication - return err + http.Error(w, err.Error(), http.StatusBadRequest) + return } initialAccessToken = iat } if err := storage.AuthorizeClientRegistration(ctx, initialAccessToken, req); err != nil { - return err + if errors.Is(err, ErrInvalidInitialAccessToken) { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + if errors.Is(err, ErrInvalidSoftwareStatement) { + clientRequestError( + w, + r, + slog.LevelInfo, + &oidc.ClientInformationErrorResponse{ + Error: oidc.ClientInformationErrorResponseErrorCodeInvalidSoftwareStatement, + ErrorDescription: err.Error(), + }, + o.Logger(), + http.StatusBadRequest, + ) + return + } + if errors.Is(err, ErrUnapprovedSoftwareStatement) { + clientRequestError( + w, + r, + slog.LevelInfo, + &oidc.ClientInformationErrorResponse{ + Error: oidc.ClientInformationErrorResponseErrorCodeUnapprovedSoftwareStatement, + ErrorDescription: err.Error(), + }, + o.Logger(), + http.StatusBadRequest, + ) + return + } + o.Logger().Log(r.Context(), slog.LevelError, "read client error", "error", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return } res, err := storage.RegisterClient(ctx, req) if err != nil { - // TODO(mqf20): be able to return the proper error codes? - return err + if errors.Is(err, ErrInvalidRedirectURI) { + clientRequestError( + w, + r, + slog.LevelInfo, + &oidc.ClientInformationErrorResponse{ + Error: oidc.ClientInformationErrorResponseErrorCodeInvalidRedirectURI, + ErrorDescription: err.Error(), + }, + o.Logger(), + http.StatusBadRequest, + ) + return + } + if errors.Is(err, ErrInvalidClientMetadata) { + clientRequestError( + w, + r, + slog.LevelInfo, + &oidc.ClientInformationErrorResponse{ + Error: oidc.ClientInformationErrorResponseErrorCodeInvalidClientMetadata, + ErrorDescription: err.Error(), + }, + o.Logger(), + http.StatusBadRequest, + ) + return + } + o.Logger().Log(r.Context(), slog.LevelError, "register client error", "error", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return } // Upon a successful registration request, the authorization server @@ -172,7 +251,7 @@ func clientRegistration(w http.ResponseWriter, r *http.Request, o OpenIDProvider // containing a Client Information Response. httphelper.MarshalJSONWithStatus(w, res, http.StatusCreated) - return nil + return } func ParseClientRegistrationRequest(r *http.Request) (*oidc.ClientRegistrationRequest, error) { @@ -193,39 +272,87 @@ func ParseClientRegistrationRequest(r *http.Request) (*oidc.ClientRegistrationRe // // [client update requests]: https://www.rfc-editor.org/rfc/rfc7592.html#section-2.2 // [OAuth 2.0 Dynamic Client Registration Management Protocol]: https://www.rfc-editor.org/rfc/rfc7592.html -func clientUpdate(w http.ResponseWriter, r *http.Request, o OpenIDProvider) error { +func clientUpdate(w http.ResponseWriter, r *http.Request, o OpenIDProvider) { ctx, span := tracer.Start(r.Context(), "clientUpdate") r = r.WithContext(ctx) defer span.End() storage, err := assertClientStorage(o.Storage()) if err != nil { - return err + http.Error(w, err.Error(), http.StatusBadRequest) + return } req, err := ParseClientUpdateRequest(r, o) if err != nil { - // TODO(mqf20): be able to return the proper error codes? - return err + http.Error(w, err.Error(), http.StatusBadRequest) + return } registrationAccessToken, err := getBearerToken(r) if err != nil { - return err + http.Error(w, err.Error(), http.StatusBadRequest) + return } if err := storage.AuthorizeClientUpdate(ctx, req.ClientID, registrationAccessToken); err != nil { - return err + if errors.Is(err, ErrInvalidClient) || errors.Is(err, ErrInvalidRegistrationAccessToken) { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + if errors.Is(err, ErrClientNoPermission) { + http.Error(w, err.Error(), http.StatusForbidden) + return + } + http.Error(w, err.Error(), http.StatusBadRequest) + return } res, err := storage.UpdateClient(ctx, req) if err != nil { - // TODO(mqf20): be able to return the proper error codes? - return err + if errors.Is(err, ErrInvalidClient) { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + if errors.Is(err, ErrInvalidRedirectURI) { + clientRequestError( + w, + r, + slog.LevelInfo, + &oidc.ClientInformationErrorResponse{ + Error: oidc.ClientInformationErrorResponseErrorCodeInvalidRedirectURI, + ErrorDescription: err.Error(), + }, + o.Logger(), + http.StatusBadRequest, + ) + return + } + if errors.Is(err, ErrInvalidClientMetadata) { + clientRequestError( + w, + r, + slog.LevelInfo, + &oidc.ClientInformationErrorResponse{ + Error: oidc.ClientInformationErrorResponseErrorCodeInvalidClientMetadata, + ErrorDescription: err.Error(), + }, + o.Logger(), + http.StatusBadRequest, + ) + return + } + if errors.Is(err, ErrClientUpdateNotAllowed) { + http.Error(w, err.Error(), http.StatusForbidden) + return + } + o.Logger().Log(r.Context(), slog.LevelError, "update client error", "error", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return } httphelper.MarshalJSON(w, res) - return nil + return } func ParseClientUpdateRequest(r *http.Request, o OpenIDProvider) (*oidc.ClientUpdateRequest, error) { @@ -246,38 +373,62 @@ func ParseClientUpdateRequest(r *http.Request, o OpenIDProvider) (*oidc.ClientUp // // [client delete requests]: https://www.rfc-editor.org/rfc/rfc7592.html#section-2.3 // [OAuth 2.0 Dynamic Client Registration Management Protocol]: https://www.rfc-editor.org/rfc/rfc7592.html -func clientDelete(w http.ResponseWriter, r *http.Request, o OpenIDProvider) error { +func clientDelete(w http.ResponseWriter, r *http.Request, o OpenIDProvider) { ctx, span := tracer.Start(r.Context(), "clientDelete") r = r.WithContext(ctx) defer span.End() storage, err := assertClientStorage(o.Storage()) if err != nil { - return err + http.Error(w, err.Error(), http.StatusBadRequest) + return } req, err := ParseClientDeleteRequest(r, o) if err != nil { - // TODO(mqf20): be able to return the proper error codes? - return err + http.Error(w, err.Error(), http.StatusBadRequest) + return } registrationAccessToken, err := getBearerToken(r) if err != nil { - return err + http.Error(w, err.Error(), http.StatusBadRequest) + return } if err := storage.AuthorizeClientDelete(ctx, req.ClientID, registrationAccessToken); err != nil { - return err + if errors.Is(err, ErrInvalidClient) || errors.Is(err, ErrInvalidRegistrationAccessToken) { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + if errors.Is(err, ErrClientNoPermission) { + http.Error(w, err.Error(), http.StatusForbidden) + return + } + http.Error(w, err.Error(), http.StatusBadRequest) + return } if err := storage.DeleteClient(ctx, req.ClientID); err != nil { - // TODO(mqf20): be able to return the proper error codes? - return err + if errors.Is(err, ErrInvalidClient) { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + if errors.Is(err, ErrClientDeleteNotSupported) { + http.Error(w, err.Error(), http.StatusMethodNotAllowed) + return + } + if errors.Is(err, ErrClientDeleteNotAllowed) { + http.Error(w, err.Error(), http.StatusForbidden) + return + } + o.Logger().Log(r.Context(), slog.LevelError, "delete client error", "error", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return } w.WriteHeader(http.StatusNoContent) - return nil + return } func ParseClientDeleteRequest(r *http.Request, o OpenIDProvider) (*oidc.ClientDeleteRequest, error) { diff --git a/pkg/op/storage.go b/pkg/op/storage.go index daacf22e..cd0d7424 100644 --- a/pkg/op/storage.go +++ b/pkg/op/storage.go @@ -211,25 +211,64 @@ func assertDeviceStorage(s Storage) (DeviceAuthorizationStorage, error) { return storage, nil } +var ( + ErrInvalidClient = errors.New("invalid client") + ErrInvalidRegistrationAccessToken = errors.New("invalid registration access token") + ErrClientNoPermission = errors.New("client no permission") + ErrInvalidRedirectURI = errors.New("invalid redirect_uri") + ErrInvalidClientMetadata = errors.New("invalid client metadata") + ErrInvalidSoftwareStatement = errors.New("invalid software statement") + ErrUnapprovedSoftwareStatement = errors.New("unapproved software statement") + ErrInvalidInitialAccessToken = errors.New("invalid initial access token") + ErrClientUpdateNotAllowed = errors.New("client update not allowed") + ErrClientDeleteNotSupported = errors.New("client delete not supported") + ErrClientDeleteNotAllowed = errors.New("client delete not allowed") +) + // ClientsStorage is required to implement dynamic client registration. type ClientsStorage interface { // RegisterClient handles the Client Registration Request according to [RFC7591]. // + // If the value of one or more redirection URIs is invalid, return an ErrInvalidRedirectURI. + // + // If the value of one of the client metadata fields is invalid and the server has rejected this request, + // return an ErrInvalidClientMetadata. + // // [RFC7591]: https://www.rfc-editor.org/rfc/rfc7591#section-3.1 RegisterClient(ctx context.Context, c *oidc.ClientRegistrationRequest) (*oidc.ClientRegistrationResponse, error) // ReadClient handles the Client Read Request according to [RFC7592]. // + // If the Client does not exist on this server, + // or the Client is invalid, return an ErrInvalidClient. + // // [RFC7592]: https://www.rfc-editor.org/rfc/rfc7592.html#section-2.1 ReadClient(ctx context.Context, clientID string) (*oidc.ClientReadResponse, error) // UpdateClient handles the Client Update Request according to [RFC7592]. // + // If the Client does not exist on this server, + // or the Client is invalid, return an ErrInvalidClient. + // + // If the value of one or more redirection URIs is invalid, return an ErrInvalidRedirectURI. + // + // If the value of one of the client metadata fields is invalid and the server has rejected this request, + // return an ErrInvalidClientMetadata. + // + // If the client is not allowed to update its records, return an ErrClientUpdateNotAllowed. + // // [RFC7592]: https://www.rfc-editor.org/rfc/rfc7592.html#section-2.2 UpdateClient(ctx context.Context, c *oidc.ClientUpdateRequest) (*oidc.ClientInformationResponse, error) // DeleteClient handles the Client Delete Request according to [RFC7592]. // + // If the Client does not exist on this server, + // or the Client is invalid, return an ErrInvalidClient. + // + // If the server does not support the delete method, return ErrClientDeleteNotSupported. + // + // If the client is not allowed to delete itself, return ErrClientDeleteNotAllowed. + // // [RFC7592]: https://www.rfc-editor.org/rfc/rfc7592.html#section-2.3 DeleteClient(ctx context.Context, clientID string) error @@ -241,6 +280,13 @@ type ClientsStorage interface { // - a software statement (A digitally signed or MACed JSON Web Token (JWT) [RFC7519] that asserts metadata // values about the client software.) // + // If the initial access token is invalid, return an ErrInvalidInitialAccessToken. + // + // If the software statement is invalid, return an ErrInvalidSoftwareStatement. + // + // If the software statement presented is not approved for use by this authorization server, return an + // ErrUnapprovedSoftwareStatement. + // // [RFC7591]: https://www.rfc-editor.org/rfc/rfc7591 // [RFC7519]: https://www.rfc-editor.org/rfc/rfc7519 AuthorizeClientRegistration(ctx context.Context, initialAccessToken string, c *oidc.ClientRegistrationRequest) error @@ -248,17 +294,41 @@ type ClientsStorage interface { // AuthorizeClientRead will check if a Client Read Request ([RFC7592]) is authorized for // [Protected Dynamic Client Registration]. // + // If the Client does not exist on this server, + // or the Client is invalid, return an ErrInvalidClient. + // + // If the Registration Access Token used is invalid, return an ErrInvalidRegistrationAccessToken. + // + // If the Client does not have permission to read its record, + // return an ErrClientNoPermission. + // // [RFC7592]: https://www.rfc-editor.org/rfc/rfc7592 // [Protected Dynamic Client Registration]: https://www.rfc-editor.org/rfc/rfc7591#appendix-A.1.2 AuthorizeClientRead(ctx context.Context, clientID, registrationAccessToken string) error // AuthorizeClientUpdate will check if a Client Update Request ([RFC7592]) is authorized. // + // If the Client does not exist on this server, + // or the Client is invalid, return an ErrInvalidClient. + // + // If the Registration Access Token used is invalid, return an ErrInvalidRegistrationAccessToken. + // + // If the Client does not have permission to read its record, + // return an ErrClientNoPermission. + // // [RFC7592]: https://www.rfc-editor.org/rfc/rfc7592 AuthorizeClientUpdate(ctx context.Context, clientID, registrationAccessToken string) error // AuthorizeClientDelete will check if a Client Delete Request ([RFC7592]) is authorized. // + // If the Client does not exist on this server, + // or the Client is invalid, return an ErrInvalidClient. + // + // If the Registration Access Token used is invalid, return an ErrInvalidRegistrationAccessToken. + // + // If the Client does not have permission to read its record, + // return an ErrClientNoPermission. + // // [RFC7592]: https://www.rfc-editor.org/rfc/rfc7592 AuthorizeClientDelete(ctx context.Context, clientID, registrationAccessToken string) error } From 542d2c4d376ba2ec89fc06bb15c757c0aaa8c39a Mon Sep 17 00:00:00 2001 From: mqf20 Date: Fri, 8 Aug 2025 23:21:42 +0800 Subject: [PATCH 29/44] WIP fixed unit tests Signed-off-by: mqf20 --- pkg/op/op_test.go | 2 +- pkg/op/server_http_routes_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/op/op_test.go b/pkg/op/op_test.go index c1520e22..bb3780f7 100644 --- a/pkg/op/op_test.go +++ b/pkg/op/op_test.go @@ -154,7 +154,7 @@ func TestRoutes(t *testing.T) { method: http.MethodGet, path: oidc.DiscoveryEndpoint, wantCode: http.StatusOK, - json: `{"issuer":"https://localhost:9998/","authorization_endpoint":"https://localhost:9998/authorize","token_endpoint":"https://localhost:9998/oauth/token","introspection_endpoint":"https://localhost:9998/oauth/introspect","userinfo_endpoint":"https://localhost:9998/userinfo","revocation_endpoint":"https://localhost:9998/revoke","end_session_endpoint":"https://localhost:9998/end_session","device_authorization_endpoint":"https://localhost:9998/device_authorization","jwks_uri":"https://localhost:9998/keys","scopes_supported":["openid","profile","email","phone","address","offline_access"],"response_types_supported":["code","id_token","id_token token"],"grant_types_supported":["authorization_code","implicit","refresh_token","client_credentials","urn:ietf:params:oauth:grant-type:token-exchange","urn:ietf:params:oauth:grant-type:jwt-bearer","urn:ietf:params:oauth:grant-type:device_code"],"subject_types_supported":["public"],"id_token_signing_alg_values_supported":["RS256"],"request_object_signing_alg_values_supported":["RS256"],"token_endpoint_auth_methods_supported":["none","client_secret_basic","client_secret_post","private_key_jwt"],"token_endpoint_auth_signing_alg_values_supported":["RS256"],"revocation_endpoint_auth_methods_supported":["none","client_secret_basic","client_secret_post","private_key_jwt"],"revocation_endpoint_auth_signing_alg_values_supported":["RS256"],"introspection_endpoint_auth_methods_supported":["client_secret_basic","private_key_jwt"],"introspection_endpoint_auth_signing_alg_values_supported":["RS256"],"claims_supported":["sub","aud","exp","iat","iss","auth_time","nonce","acr","amr","c_hash","at_hash","act","scopes","client_id","azp","preferred_username","name","family_name","given_name","locale","email","email_verified","phone_number","phone_number_verified"],"code_challenge_methods_supported":["S256"],"ui_locales_supported":["en"],"request_parameter_supported":true,"request_uri_parameter_supported":false}`, + json: `{"issuer":"https://localhost:9998/","authorization_endpoint":"https://localhost:9998/authorize","token_endpoint":"https://localhost:9998/oauth/token","introspection_endpoint":"https://localhost:9998/oauth/introspect","userinfo_endpoint":"https://localhost:9998/userinfo","revocation_endpoint":"https://localhost:9998/revoke","end_session_endpoint":"https://localhost:9998/end_session","device_authorization_endpoint":"https://localhost:9998/device_authorization","jwks_uri":"https://localhost:9998/keys","scopes_supported":["openid","profile","email","phone","address","offline_access"],"response_types_supported":["code","id_token","id_token token"],"grant_types_supported":["authorization_code","implicit","refresh_token","client_credentials","urn:ietf:params:oauth:grant-type:token-exchange","urn:ietf:params:oauth:grant-type:jwt-bearer","urn:ietf:params:oauth:grant-type:device_code"],"subject_types_supported":["public"],"id_token_signing_alg_values_supported":["RS256"],"request_object_signing_alg_values_supported":["RS256"],"token_endpoint_auth_methods_supported":["none","client_secret_basic","client_secret_post","private_key_jwt"],"token_endpoint_auth_signing_alg_values_supported":["RS256"],"revocation_endpoint_auth_methods_supported":["none","client_secret_basic","client_secret_post","private_key_jwt"],"revocation_endpoint_auth_signing_alg_values_supported":["RS256"],"introspection_endpoint_auth_methods_supported":["client_secret_basic","private_key_jwt"],"introspection_endpoint_auth_signing_alg_values_supported":["RS256"],"claims_supported":["sub","aud","exp","iat","iss","auth_time","nonce","acr","amr","c_hash","at_hash","act","scopes","client_id","azp","preferred_username","name","family_name","given_name","locale","email","email_verified","phone_number","phone_number_verified"],"code_challenge_methods_supported":["S256"],"ui_locales_supported":["en"],"request_parameter_supported":true,"request_uri_parameter_supported":false,"registration_endpoint" : "https://localhost:9998/oauth/register"}`, }, { name: "authorization", diff --git a/pkg/op/server_http_routes_test.go b/pkg/op/server_http_routes_test.go index e0e4a978..8c5e6b60 100644 --- a/pkg/op/server_http_routes_test.go +++ b/pkg/op/server_http_routes_test.go @@ -104,7 +104,7 @@ func TestServerRoutes(t *testing.T) { method: http.MethodGet, path: oidc.DiscoveryEndpoint, wantCode: http.StatusOK, - json: `{"issuer":"https://localhost:9998/","authorization_endpoint":"https://localhost:9998/authorize","token_endpoint":"https://localhost:9998/oauth/token","introspection_endpoint":"https://localhost:9998/oauth/introspect","userinfo_endpoint":"https://localhost:9998/userinfo","revocation_endpoint":"https://localhost:9998/revoke","end_session_endpoint":"https://localhost:9998/end_session","device_authorization_endpoint":"https://localhost:9998/device_authorization","jwks_uri":"https://localhost:9998/keys","scopes_supported":["openid","profile","email","phone","address","offline_access"],"response_types_supported":["code","id_token","id_token token"],"grant_types_supported":["authorization_code","implicit","refresh_token","client_credentials","urn:ietf:params:oauth:grant-type:token-exchange","urn:ietf:params:oauth:grant-type:jwt-bearer","urn:ietf:params:oauth:grant-type:device_code"],"subject_types_supported":["public"],"id_token_signing_alg_values_supported":["RS256"],"request_object_signing_alg_values_supported":["RS256"],"token_endpoint_auth_methods_supported":["none","client_secret_basic","client_secret_post","private_key_jwt"],"token_endpoint_auth_signing_alg_values_supported":["RS256"],"revocation_endpoint_auth_methods_supported":["none","client_secret_basic","client_secret_post","private_key_jwt"],"revocation_endpoint_auth_signing_alg_values_supported":["RS256"],"introspection_endpoint_auth_methods_supported":["client_secret_basic","private_key_jwt"],"introspection_endpoint_auth_signing_alg_values_supported":["RS256"],"claims_supported":["sub","aud","exp","iat","iss","auth_time","nonce","acr","amr","c_hash","at_hash","act","scopes","client_id","azp","preferred_username","name","family_name","given_name","locale","email","email_verified","phone_number","phone_number_verified"],"code_challenge_methods_supported":["S256"],"ui_locales_supported":["en"],"request_parameter_supported":true,"request_uri_parameter_supported":false}`, + json: `{"issuer":"https://localhost:9998/","authorization_endpoint":"https://localhost:9998/authorize","token_endpoint":"https://localhost:9998/oauth/token","introspection_endpoint":"https://localhost:9998/oauth/introspect","userinfo_endpoint":"https://localhost:9998/userinfo","revocation_endpoint":"https://localhost:9998/revoke","end_session_endpoint":"https://localhost:9998/end_session","device_authorization_endpoint":"https://localhost:9998/device_authorization","jwks_uri":"https://localhost:9998/keys","scopes_supported":["openid","profile","email","phone","address","offline_access"],"response_types_supported":["code","id_token","id_token token"],"grant_types_supported":["authorization_code","implicit","refresh_token","client_credentials","urn:ietf:params:oauth:grant-type:token-exchange","urn:ietf:params:oauth:grant-type:jwt-bearer","urn:ietf:params:oauth:grant-type:device_code"],"subject_types_supported":["public"],"id_token_signing_alg_values_supported":["RS256"],"request_object_signing_alg_values_supported":["RS256"],"token_endpoint_auth_methods_supported":["none","client_secret_basic","client_secret_post","private_key_jwt"],"token_endpoint_auth_signing_alg_values_supported":["RS256"],"revocation_endpoint_auth_methods_supported":["none","client_secret_basic","client_secret_post","private_key_jwt"],"revocation_endpoint_auth_signing_alg_values_supported":["RS256"],"introspection_endpoint_auth_methods_supported":["client_secret_basic","private_key_jwt"],"introspection_endpoint_auth_signing_alg_values_supported":["RS256"],"claims_supported":["sub","aud","exp","iat","iss","auth_time","nonce","acr","amr","c_hash","at_hash","act","scopes","client_id","azp","preferred_username","name","family_name","given_name","locale","email","email_verified","phone_number","phone_number_verified"],"code_challenge_methods_supported":["S256"],"ui_locales_supported":["en"],"request_parameter_supported":true,"request_uri_parameter_supported":false,"registration_endpoint" : "https://localhost:9998/oauth/register"}`, }, { name: "authorization", From a58f16f909fbcd86588959b9871f4e25c3a8fbdb Mon Sep 17 00:00:00 2001 From: mqf20 Date: Fri, 8 Aug 2025 23:38:54 +0800 Subject: [PATCH 30/44] WIP fixed example Signed-off-by: mqf20 --- example/server/storage/client.go | 1 + example/server/storage/storage.go | 273 +++++++++++++++--------------- 2 files changed, 136 insertions(+), 138 deletions(-) diff --git a/example/server/storage/client.go b/example/server/storage/client.go index 010b9ce7..45e52ec6 100644 --- a/example/server/storage/client.go +++ b/example/server/storage/client.go @@ -34,6 +34,7 @@ type Client struct { clockSkew time.Duration postLogoutRedirectURIGlobs []string redirectURIGlobs []string + registrationAccessToken string } // GetID must return the client_id diff --git a/example/server/storage/storage.go b/example/server/storage/storage.go index 949075a4..1a97f4ff 100644 --- a/example/server/storage/storage.go +++ b/example/server/storage/storage.go @@ -951,54 +951,55 @@ func (s *Storage) RegisterClient(_ context.Context, c *oidc.ClientRegistrationRe clockSkew: 0, postLogoutRedirectURIGlobs: nil, redirectURIGlobs: nil, + registrationAccessToken: uuid.New().String(), } s.clients[client.id] = &client return &oidc.ClientRegistrationResponse{ ClientInformationResponse: oidc.ClientInformationResponse{ ClientMetadata: oidc.ClientMetadata{ - RedirectURIs: nil, - TokenEndpointAuthMethod: "", - GrantTypes: nil, - ResponseTypes: nil, - ClientName: nil, - ClientURI: nil, - LogoURI: nil, - Scope: "", - Contacts: nil, - TOSURI: nil, - PolicyURI: nil, - JWKSURI: "", - JWKS: jose.JSONWebKeySet{}, - SoftwareID: "", - SoftwareVersion: "", - ApplicationType: "", - SectorIdentifierURI: "", - SubjectType: "", - IDTokenSignedResponseAlg: "", - IDTokenEncryptedResponseAlg: "", - IDTokenEncryptedResponseEnc: "", - UserinfoSignedResponseAlg: "", - UserinfoEncryptedResponseAlg: "", - UserinfoEncryptedResponseEnc: "", - RequestObjectSigningAlg: "", - RequestObjectEncryptionAlg: "", - RequestObjectEncryptionEnc: "", - TokenEndpointAuthSigningAlg: "", - DefaultMaxAge: 0, - RequireAuthTime: false, - DefaultACRValues: nil, - InitiateLoginURI: "", - RequestURIs: nil, - PostLogoutRedirectURIs: nil, - ExtraParameters: nil, + RedirectURIs: client.redirectURIs, + TokenEndpointAuthMethod: client.authMethod, + GrantTypes: client.grantTypes, + ResponseTypes: client.responseTypes, + ClientName: map[string]string{"default": client.id}, + //ClientURI: nil, + //LogoURI: nil, + //Scope: "", + //Contacts: nil, + //TOSURI: nil, + //PolicyURI: nil, + //JWKSURI: "", + //JWKS: jose.JSONWebKeySet{}, + //SoftwareID: "", + //SoftwareVersion: "", + //ApplicationType: "", + //SectorIdentifierURI: "", + //SubjectType: "", + //IDTokenSignedResponseAlg: "", + //IDTokenEncryptedResponseAlg: "", + //IDTokenEncryptedResponseEnc: "", + //UserinfoSignedResponseAlg: "", + //UserinfoEncryptedResponseAlg: "", + //UserinfoEncryptedResponseEnc: "", + //RequestObjectSigningAlg: "", + //RequestObjectEncryptionAlg: "", + //RequestObjectEncryptionEnc: "", + //TokenEndpointAuthSigningAlg: "", + //DefaultMaxAge: 0, + //RequireAuthTime: false, + //DefaultACRValues: nil, + //InitiateLoginURI: "", + //RequestURIs: nil, + //PostLogoutRedirectURIs: nil, + //ExtraParameters: nil, }, - ClientID: "", - ClientSecret: "", - ClientIDIssuedAt: 0, - ClientSecretExpiresAt: 0, + ClientID: client.id, + ClientSecret: client.secret, + //ClientIDIssuedAt: 0, + //ClientSecretExpiresAt: 0, }, - RegistrationAccessToken: "", + RegistrationAccessToken: client.registrationAccessToken, RegistrationClientURI: "", }, nil } @@ -1014,48 +1015,48 @@ func (s *Storage) ReadClient(_ context.Context, clientID string) (*oidc.ClientRe ClientRegistrationResponse: oidc.ClientRegistrationResponse{ ClientInformationResponse: oidc.ClientInformationResponse{ ClientMetadata: oidc.ClientMetadata{ - RedirectURIs: client.redirectURIs, - TokenEndpointAuthMethod: client.authMethod, - GrantTypes: nil, - ResponseTypes: nil, - ClientName: nil, - ClientURI: nil, - LogoURI: nil, - Scope: "", - Contacts: nil, - TOSURI: nil, - PolicyURI: nil, - JWKSURI: "", - JWKS: jose.JSONWebKeySet{}, - SoftwareID: "", - SoftwareVersion: "", - ApplicationType: "", - SectorIdentifierURI: "", - SubjectType: "", - IDTokenSignedResponseAlg: "", - IDTokenEncryptedResponseAlg: "", - IDTokenEncryptedResponseEnc: "", - UserinfoSignedResponseAlg: "", - UserinfoEncryptedResponseAlg: "", - UserinfoEncryptedResponseEnc: "", - RequestObjectSigningAlg: "", - RequestObjectEncryptionAlg: "", - RequestObjectEncryptionEnc: "", - TokenEndpointAuthSigningAlg: "", - DefaultMaxAge: 0, - RequireAuthTime: false, - DefaultACRValues: nil, - InitiateLoginURI: "", - RequestURIs: nil, - PostLogoutRedirectURIs: nil, - ExtraParameters: nil, + RedirectURIs: client.redirectURIs, + TokenEndpointAuthMethod: client.authMethod, + GrantTypes: client.grantTypes, + ResponseTypes: client.responseTypes, + ClientName: map[string]string{"default": client.id}, + //ClientURI: nil, + //LogoURI: nil, + //Scope: "", + //Contacts: nil, + //TOSURI: nil, + //PolicyURI: nil, + //JWKSURI: "", + //JWKS: jose.JSONWebKeySet{}, + //SoftwareID: "", + //SoftwareVersion: "", + //ApplicationType: "", + //SectorIdentifierURI: "", + //SubjectType: "", + //IDTokenSignedResponseAlg: "", + //IDTokenEncryptedResponseAlg: "", + //IDTokenEncryptedResponseEnc: "", + //UserinfoSignedResponseAlg: "", + //UserinfoEncryptedResponseAlg: "", + //UserinfoEncryptedResponseEnc: "", + //RequestObjectSigningAlg: "", + //RequestObjectEncryptionAlg: "", + //RequestObjectEncryptionEnc: "", + //TokenEndpointAuthSigningAlg: "", + //DefaultMaxAge: 0, + //RequireAuthTime: false, + //DefaultACRValues: nil, + //InitiateLoginURI: "", + //RequestURIs: nil, + //PostLogoutRedirectURIs: nil, + //ExtraParameters: nil, }, - ClientID: "", - ClientSecret: "", - ClientIDIssuedAt: 0, - ClientSecretExpiresAt: 0, + ClientID: client.id, + ClientSecret: client.secret, + //ClientIDIssuedAt: 0, + //ClientSecretExpiresAt: 0, }, - RegistrationAccessToken: "", + RegistrationAccessToken: client.registrationAccessToken, RegistrationClientURI: "", }, }, nil @@ -1068,66 +1069,52 @@ func (s *Storage) UpdateClient(_ context.Context, c *oidc.ClientUpdateRequest) ( if !ok { return nil, errors.New("client not found") } - //client.id = "" client.secret = c.ClientSecret - //client.??? = c.ClientIDIssuedAt - //client.??? = c.ClientSecretExpiresAt client.redirectURIs = c.RedirectURIs client.authMethod = c.TokenEndpointAuthMethod client.grantTypes = c.GrantTypes client.responseTypes = c.ResponseTypes - //client.??? = c.ClientName - //client.??? = c.ClientURI - //client.??? = c.LogoURI - //client.??? = c.Scope - //client.??? = c.Contacts - //client.??? = c.TOSURI - //client.??? = c.PolicyURI - //client.??? = c.JWKSURI - //client.??? = c.JWKS - //client.??? = c.SoftwareID - //client.??? = c.SoftwareVersion return &oidc.ClientInformationResponse{ ClientMetadata: oidc.ClientMetadata{ - RedirectURIs: nil, - TokenEndpointAuthMethod: "", - GrantTypes: nil, - ResponseTypes: nil, - ClientName: nil, - ClientURI: nil, - LogoURI: nil, - Scope: "", - Contacts: nil, - TOSURI: nil, - PolicyURI: nil, - JWKSURI: "", - JWKS: jose.JSONWebKeySet{}, - SoftwareID: "", - SoftwareVersion: "", - ApplicationType: "", - SectorIdentifierURI: "", - SubjectType: "", - IDTokenSignedResponseAlg: "", - IDTokenEncryptedResponseAlg: "", - IDTokenEncryptedResponseEnc: "", - UserinfoSignedResponseAlg: "", - UserinfoEncryptedResponseAlg: "", - UserinfoEncryptedResponseEnc: "", - RequestObjectSigningAlg: "", - RequestObjectEncryptionAlg: "", - RequestObjectEncryptionEnc: "", - TokenEndpointAuthSigningAlg: "", - DefaultMaxAge: 0, - RequireAuthTime: false, - DefaultACRValues: nil, - InitiateLoginURI: "", - RequestURIs: nil, - PostLogoutRedirectURIs: nil, - ExtraParameters: nil, + RedirectURIs: client.redirectURIs, + TokenEndpointAuthMethod: client.authMethod, + GrantTypes: client.grantTypes, + ResponseTypes: client.responseTypes, + ClientName: map[string]string{"default": client.id}, + //ClientURI: nil, + //LogoURI: nil, + //Scope: "", + //Contacts: nil, + //TOSURI: nil, + //PolicyURI: nil, + //JWKSURI: "", + //JWKS: jose.JSONWebKeySet{}, + //SoftwareID: "", + //SoftwareVersion: "", + //ApplicationType: "", + //SectorIdentifierURI: "", + //SubjectType: "", + //IDTokenSignedResponseAlg: "", + //IDTokenEncryptedResponseAlg: "", + //IDTokenEncryptedResponseEnc: "", + //UserinfoSignedResponseAlg: "", + //UserinfoEncryptedResponseAlg: "", + //UserinfoEncryptedResponseEnc: "", + //RequestObjectSigningAlg: "", + //RequestObjectEncryptionAlg: "", + //RequestObjectEncryptionEnc: "", + //TokenEndpointAuthSigningAlg: "", + //DefaultMaxAge: 0, + //RequireAuthTime: false, + //DefaultACRValues: nil, + //InitiateLoginURI: "", + //RequestURIs: nil, + //PostLogoutRedirectURIs: nil, + //ExtraParameters: nil, }, - //ClientID: "", - //ClientSecret: "", + ClientID: client.id, + ClientSecret: client.secret, //ClientIDIssuedAt: 0, //ClientSecretExpiresAt: 0, }, nil @@ -1143,22 +1130,32 @@ func (s *Storage) DeleteClient(_ context.Context, clientID string) error { func (s *Storage) AuthorizeClientRegistration(ctx context.Context, initialAccessToken string, c *oidc.ClientRegistrationRequest) error { if initialAccessToken != "verysecure" { - return errors.New("invalid initial access token") + return op.ErrInvalidInitialAccessToken + } + return nil +} + +func (s *Storage) authorizeClient(clientID, registrationAccessToken string) error { + s.lock.Lock() + defer s.lock.Unlock() + c, ok := s.clients[clientID] + if !ok { + return op.ErrInvalidClient + } + if registrationAccessToken != c.registrationAccessToken { + return op.ErrInvalidRegistrationAccessToken } return nil } func (s *Storage) AuthorizeClientRead(ctx context.Context, clientID, registrationAccessToken string) error { - //TODO implement me - panic("implement me") + return s.authorizeClient(clientID, registrationAccessToken) } func (s *Storage) AuthorizeClientUpdate(ctx context.Context, clientID, registrationAccessToken string) error { - //TODO implement me - panic("implement me") + return s.authorizeClient(clientID, registrationAccessToken) } func (s *Storage) AuthorizeClientDelete(ctx context.Context, clientID, registrationAccessToken string) error { - //TODO implement me - panic("implement me") + return s.authorizeClient(clientID, registrationAccessToken) } From 5fd99753d99ee6e24dc8a63ded92af982d308224 Mon Sep 17 00:00:00 2001 From: mqf20 Date: Sat, 9 Aug 2025 22:18:51 +0800 Subject: [PATCH 31/44] WIP cleaned up checks Signed-off-by: mqf20 --- pkg/oidc/dynamic_client_registration.go | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/pkg/oidc/dynamic_client_registration.go b/pkg/oidc/dynamic_client_registration.go index 047d84c0..29b68a30 100644 --- a/pkg/oidc/dynamic_client_registration.go +++ b/pkg/oidc/dynamic_client_registration.go @@ -749,6 +749,11 @@ func (c *ClientMetadata) UnmarshalJSON(data []byte) error { // Set default values + if c.ApplicationType == "" { + // The default, if omitted, is op.ApplicationTypeWeb. + c.ApplicationType = "web" + } + if c.TokenEndpointAuthMethod == "" { // If unspecified or omitted, // the default is "client_secret_basic", denoting the HTTP Basic @@ -951,13 +956,6 @@ func (c ClientMetadata) MarshalJSON() ([]byte, error) { res[key] = value } - // Sanity checks - - if c.JWKSURI != "" && len(c.JWKS.Keys) > 0 { - // The "jwks_uri" and "jwks" parameters MUST NOT both be present in the same request or response. - return nil, errors.New("jwks_uri and jwks cannot both be present") - } - return json.Marshal(res) } From 19eb6af19ac5f93ed669c1c6a1dce21fc06c1840 Mon Sep 17 00:00:00 2001 From: mqf20 Date: Sat, 9 Aug 2025 23:49:27 +0800 Subject: [PATCH 32/44] WIP added InternationalizedField Signed-off-by: mqf20 --- .../internationalized_field.go | 0 .../internationalized_field_test.go | 32 +++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 pkg/internationalizedfield/internationalized_field.go create mode 100644 pkg/internationalizedfield/internationalized_field_test.go diff --git a/pkg/internationalizedfield/internationalized_field.go b/pkg/internationalizedfield/internationalized_field.go new file mode 100644 index 00000000..e69de29b diff --git a/pkg/internationalizedfield/internationalized_field_test.go b/pkg/internationalizedfield/internationalized_field_test.go new file mode 100644 index 00000000..9c7c0565 --- /dev/null +++ b/pkg/internationalizedfield/internationalized_field_test.go @@ -0,0 +1,32 @@ +package internationalizedfield + +import "testing" + +func TestInternationalizedField_UnmarshalJSON(t *testing.T) { + type fields struct { + fieldName string + internal internal + } + type args struct { + data []byte + } + tests := []struct { + name string + fields fields + args args + wantErr bool + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + i := &InternationalizedField{ + fieldName: tt.fields.fieldName, + internal: tt.fields.internal, + } + if err := i.UnmarshalJSON(tt.args.data); (err != nil) != tt.wantErr { + t.Errorf("UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} From 69a47d5b12564943b68db30c0416750f0b92c188 Mon Sep 17 00:00:00 2001 From: mqf20 Date: Sat, 9 Aug 2025 23:49:59 +0800 Subject: [PATCH 33/44] WIP added InternationalizedField Signed-off-by: mqf20 --- .../internationalized_field.go | 87 +++++++++++++++ .../internationalized_field_test.go | 105 +++++++++++++----- 2 files changed, 165 insertions(+), 27 deletions(-) diff --git a/pkg/internationalizedfield/internationalized_field.go b/pkg/internationalizedfield/internationalized_field.go index e69de29b..b62b2a33 100644 --- a/pkg/internationalizedfield/internationalized_field.go +++ b/pkg/internationalizedfield/internationalized_field.go @@ -0,0 +1,87 @@ +package internationalizedfield + +import ( + "encoding/json" + "fmt" + "golang.org/x/text/language" + "strings" +) + +type languageMap = map[language.Tag]string + +// InternationalizedField models a JSON field that is used to represent [Human-Readable Client Metadata]. +// +// It references human-readable values and may be represented in multiple languages and scripts. +// +// To specify the languages and scripts, BCP 47 [RFC5646] language tags are added to client metadata member names, +// delimited by a "#" character. +// +// For example, a client could represent its name in English as +// +// "client_name#en": "My Client" +// +// and its name in Japanese as +// +// "client_name#ja-Jpan-JP": "\u30AF\u30E9\u30A4\u30A2\u30F3\u30C8\u540D" +// +// within the same registration request. +// +// [Human-Readable Client Metadata]: https://www.rfc-editor.org/rfc/rfc7591#section-2.2 +type InternationalizedField struct { + fieldName string + Items languageMap +} + +func New(fieldName string) *InternationalizedField { + return &InternationalizedField{ + fieldName: fieldName, + Items: make(languageMap), + } +} + +func (i *InternationalizedField) UnmarshalJSON(data []byte) error { + i.Items = make(languageMap) + + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + return fmt.Errorf("could not unmarshal raw data: %w", err) + } + + for key, value := range raw { + switch { + case key == i.fieldName: + if name, ok := value.(string); ok { + // This is the default, non-tagged name. + i.Items[language.Und] = name + } + case strings.HasPrefix(key, i.fieldName+"#"): + if name, ok := value.(string); ok { + // This is a tagged name, e.g., "client_name#ja-Jpan-JP" + // Split the key at the first '#' to get the language tag. + parts := strings.SplitN(key, "#", 2) + if len(parts) == 2 { + langTag := parts[1] + if t, err := language.Parse(langTag); err != nil { + return fmt.Errorf("could not parse language tag %q: %w", langTag, err) + } else { + i.Items[t] = name + } + } + } + } + } + return nil +} +func (i InternationalizedField) MarshalJSON() ([]byte, error) { + res := make(map[string]interface{}) + if len(i.Items) > 0 { + for lang, name := range i.Items { + if lang == language.Und { + res[i.fieldName] = name + } else { + res[fmt.Sprintf("%s#%s", i.fieldName, lang)] = name + } + } + } + return json.Marshal(res) +} diff --git a/pkg/internationalizedfield/internationalized_field_test.go b/pkg/internationalizedfield/internationalized_field_test.go index 9c7c0565..a1df1d3f 100644 --- a/pkg/internationalizedfield/internationalized_field_test.go +++ b/pkg/internationalizedfield/internationalized_field_test.go @@ -1,32 +1,83 @@ package internationalizedfield -import "testing" +import ( + "encoding/json" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/text/language" + "testing" +) func TestInternationalizedField_UnmarshalJSON(t *testing.T) { - type fields struct { - fieldName string - internal internal - } - type args struct { - data []byte - } - tests := []struct { - name string - fields fields - args args - wantErr bool - }{ - // TODO: Add test cases. - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - i := &InternationalizedField{ - fieldName: tt.fields.fieldName, - internal: tt.fields.internal, - } - if err := i.UnmarshalJSON(tt.args.data); (err != nil) != tt.wantErr { - t.Errorf("UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr) - } - }) - } + wantJPTag, err := language.Parse("ja-Jpan-JP") + require.NoError(t, err) + t.Run("unmarshal valid JSON", func(t *testing.T) { + marshalled1 := []byte(` +{ + "client_name": "My Example", + "client_name#ja-Jpan-JP": "クライアント名" +} +`) + req1 := New("client_name") + require.NoError(t, json.Unmarshal(marshalled1, &req1)) + + require.Len(t, req1.Items, 2) + assert.Contains(t, req1.Items, language.Und) + assert.Contains(t, req1.Items, wantJPTag) + + assert.Equal(t, "My Example", req1.Items[language.Und]) + assert.Equal(t, "クライアント名", req1.Items[wantJPTag]) + }) + t.Run("unmarshal valid JSON with missing field", func(t *testing.T) { + marshalled1 := []byte(` +{ + "hello": "world" +} +`) + req1 := New("client_name") + require.NoError(t, json.Unmarshal(marshalled1, &req1)) + + require.Empty(t, req1.Items) + }) + t.Run("unmarshal JSON with invalid tag", func(t *testing.T) { + require.NoError(t, err) + + marshalled1 := []byte(` +{ + "client_name": "My Example", + "client_name#invalid_tag": "hello world", +} +`) + req1 := New("client_name") + require.Error(t, json.Unmarshal(marshalled1, &req1)) + }) +} + +func TestInternationalizedField_MarshalJSON(t *testing.T) { + wantJPTag, err := language.Parse("ja-Jpan-JP") + require.NoError(t, err) + t.Run("marshal valid JSON", func(t *testing.T) { + want := []byte(` +{ + "client_name": "My Example", + "client_name#ja-Jpan-JP": "クライアント名" +} +`) + req := New("client_name") + req.Items[language.Und] = "My Example" + req.Items[wantJPTag] = "クライアント名" + + marshalled, err := json.Marshal(req) + require.NoError(t, err) + + assert.JSONEq(t, string(marshalled), string(want)) + }) + t.Run("marshal empty JSON", func(t *testing.T) { + req := New("client_name") + + marshalled, err := json.Marshal(req) + require.NoError(t, err) + + assert.JSONEq(t, string(marshalled), `{}`) + }) } From 7e0fa8bb28233249c2b4f8d374feb0a39b12eeb0 Mon Sep 17 00:00:00 2001 From: mqf20 Date: Sat, 9 Aug 2025 23:50:25 +0800 Subject: [PATCH 34/44] WIP improved tests Signed-off-by: mqf20 --- pkg/oidc/dynamic_client_registration_test.go | 456 ++++++++++++------- 1 file changed, 289 insertions(+), 167 deletions(-) diff --git a/pkg/oidc/dynamic_client_registration_test.go b/pkg/oidc/dynamic_client_registration_test.go index dfa16e44..b689ec78 100644 --- a/pkg/oidc/dynamic_client_registration_test.go +++ b/pkg/oidc/dynamic_client_registration_test.go @@ -232,8 +232,8 @@ func TestClientRegistrationRequest(t *testing.T) { } func TestClientReadResponse(t *testing.T) { // example from https://openid.net/specs/openid-connect-registration-1_0.html#ReadResponse - t.Run("unmarshal, marshal and unmarshal example", func(t *testing.T) { - marshalled1 := []byte(` + t.Run("marshal example", func(t *testing.T) { + want := ` { "client_id": "s6BhdRkqt3", "client_secret": "OylyaC56ijpAQ7G5ZZGL7MMQ6Ap6mEeuhSTFVps2N4Q", @@ -253,108 +253,130 @@ func TestClientReadResponse(t *testing.T) { "contacts": ["ve7jtb@example.org", "mary@example.org"], "request_uris": ["https://client.example.org/rf.txt#qpXaRLh_n93TTR9F252ValdatUQvQiJi5BDub2BeznA"] } -`) - var req1 ClientReadResponse - require.NoError(t, json.Unmarshal(marshalled1, &req1)) - - marshalled2, err2 := json.Marshal(req1) - require.NoError(t, err2) - - var req3 ClientReadResponse - require.NoError(t, json.Unmarshal(marshalled2, &req3)) +` + res := ClientReadResponse{ + ClientRegistrationResponse: ClientRegistrationResponse{ + ClientInformationResponse: ClientInformationResponse{ + ClientMetadata: ClientMetadata{ + RedirectURIs: []string{ + "https://client.example.org/callback", + "https://client.example.org/callback2", + }, + TokenEndpointAuthMethod: AuthMethodBasic, + GrantTypes: nil, + ResponseTypes: nil, + ClientName: map[string]string{ + "default": "My Example", + "ja-Jpan-JP": "クライアント名", + }, + ClientURI: nil, + LogoURI: map[string]string{ + "default": "https://client.example.org/logo.png", + }, + //Scope: "", + Contacts: []string{"ve7jtb@example.org", "mary@example.org"}, + //TOSURI: nil, + //PolicyURI: nil, + JWKSURI: "https://client.example.org/my_public_keys.jwks", + //JWKS: jose.JSONWebKeySet{}, + //SoftwareID: "", + //SoftwareVersion: "", + ApplicationType: "web", // cannot use op.ApplicationTypeWeb because of cyclic imports + SectorIdentifierURI: "https://other.example.net/file_of_redirect_uris.json", + SubjectType: "pairwise", + //IDTokenSignedResponseAlg: "", + //IDTokenEncryptedResponseAlg: "", + //IDTokenEncryptedResponseEnc: "", + //UserinfoSignedResponseAlg: "", + UserinfoEncryptedResponseAlg: "RSA-OAEP-256", + UserinfoEncryptedResponseEnc: "A128CBC-HS256", + //RequestObjectSigningAlg: "", + //RequestObjectEncryptionAlg: "", + //RequestObjectEncryptionEnc: "", + //TokenEndpointAuthSigningAlg: "", + //DefaultMaxAge: 0, + //RequireAuthTime: false, + //DefaultACRValues: nil, + //InitiateLoginURI: "", + RequestURIs: []string{"https://client.example.org/rf.txt#qpXaRLh_n93TTR9F252ValdatUQvQiJi5BDub2BeznA"}, + //PostLogoutRedirectURIs: nil, + //ExtraParameters: nil, + }, + ClientID: "s6BhdRkqt3", + ClientSecret: "OylyaC56ijpAQ7G5ZZGL7MMQ6Ap6mEeuhSTFVps2N4Q", + //ClientIDIssuedAt: 0, + ClientSecretExpiresAt: int64(17514165600), + }, + //RegistrationAccessToken: "", + RegistrationClientURI: "https://server.example.com/connect/register?client_id=s6BhdRkqt3", + }, + } + + marshalled, err := json.Marshal(res) + require.NoError(t, err) - assert.Equal(t, "s6BhdRkqt3", req3.ClientID) - assert.Equal(t, "OylyaC56ijpAQ7G5ZZGL7MMQ6Ap6mEeuhSTFVps2N4Q", req3.ClientSecret) - assert.Equal(t, int64(17514165600), req3.ClientSecretExpiresAt) - assert.Equal(t, "https://server.example.com/connect/register?client_id=s6BhdRkqt3", req3.RegistrationClientURI) - assert.Equal(t, AuthMethodBasic, req3.TokenEndpointAuthMethod) - assert.Equal(t, "web", req3.ApplicationType) // cannot use op.ApplicationTypeWeb because of cyclic imports - assert.Len(t, req3.RedirectURIs, 2) - assert.Contains(t, req3.RedirectURIs, "https://client.example.org/callback") - assert.Contains(t, req3.RedirectURIs, "https://client.example.org/callback2") - assert.Equal(t, "My Example", req3.ClientName["default"]) - assert.Equal(t, "\u30AF\u30E9\u30A4\u30A2\u30F3\u30C8\u540D", req3.ClientName["ja-Jpan-JP"]) - assert.Len(t, req3.LogoURI, 1) - assert.Equal(t, "https://client.example.org/logo.png", req3.LogoURI["default"]) - assert.Equal(t, "pairwise", req3.SubjectType) - assert.Equal(t, "https://other.example.net/file_of_redirect_uris.json", req3.SectorIdentifierURI) - assert.Equal(t, "RSA-OAEP-256", req3.UserinfoEncryptedResponseAlg) - assert.Equal(t, "A128CBC-HS256", req3.UserinfoEncryptedResponseEnc) - assert.Len(t, req3.Contacts, 2) - assert.Contains(t, req3.Contacts, "ve7jtb@example.org") - assert.Contains(t, req3.Contacts, "mary@example.org") - assert.Len(t, req3.RequestURIs, 1) - assert.Contains(t, req3.RequestURIs, "https://client.example.org/rf.txt#qpXaRLh_n93TTR9F252ValdatUQvQiJi5BDub2BeznA") + assert.JSONEq(t, want, string(marshalled)) }) } func TestClientInformationErrorResponse(t *testing.T) { // example from https://www.rfc-editor.org/rfc/rfc7591#page-23 - t.Run("unmarshal, marshal and unmarshal example", func(t *testing.T) { - marshalled1 := []byte(` + t.Run("marshal example", func(t *testing.T) { + want := ` { "error": "invalid_redirect_uri", "error_description": "The redirection URI http://sketchy.example.com is not allowed by this server." } -`) - var req1 ClientInformationErrorResponse - require.NoError(t, json.Unmarshal(marshalled1, &req1)) - - marshalled2, err2 := json.Marshal(req1) - require.NoError(t, err2) - - var req3 ClientInformationErrorResponse - require.NoError(t, json.Unmarshal(marshalled2, &req3)) +` + res := ClientInformationErrorResponse{ + Error: ClientInformationErrorResponseErrorCodeInvalidRedirectURI, + ErrorDescription: "The redirection URI http://sketchy.example.com is not allowed by this server.", + } + marshalled, err := json.Marshal(res) + require.NoError(t, err) - assert.Equal(t, ClientInformationErrorResponseErrorCodeInvalidRedirectURI, req3.Error) - assert.Equal(t, "The redirection URI http://sketchy.example.com is not allowed by this server.", req3.ErrorDescription) + assert.JSONEq(t, want, string(marshalled)) }) // example from https://www.rfc-editor.org/rfc/rfc7591#page-23 - t.Run("unmarshal, marshal and unmarshal example", func(t *testing.T) { - marshalled1 := []byte(` + t.Run("marshal example", func(t *testing.T) { + want := ` { "error": "invalid_client_metadata", "error_description": "The grant type 'authorization_code' must be registered along with the response type 'code' but found only 'implicit' instead." } -`) - var req1 ClientInformationErrorResponse - require.NoError(t, json.Unmarshal(marshalled1, &req1)) - - marshalled2, err2 := json.Marshal(req1) - require.NoError(t, err2) - - var req3 ClientInformationErrorResponse - require.NoError(t, json.Unmarshal(marshalled2, &req3)) +` + res := ClientInformationErrorResponse{ + Error: ClientInformationErrorResponseErrorCodeInvalidClientMetadata, + ErrorDescription: "The grant type 'authorization_code' must be registered along with the response type 'code' but found only 'implicit' instead.", + } + marshalled, err := json.Marshal(res) + require.NoError(t, err) - assert.Equal(t, ClientInformationErrorResponseErrorCodeInvalidClientMetadata, req3.Error) - assert.Equal(t, "The grant type 'authorization_code' must be registered along with the response type 'code' but found only 'implicit' instead.", req3.ErrorDescription) + assert.JSONEq(t, want, string(marshalled)) }) // example from https://openid.net/specs/openid-connect-registration-1_0.html#RegistrationError t.Run("unmarshal, marshal and unmarshal example", func(t *testing.T) { - marshalled1 := []byte(` + want := ` { "error": "invalid_redirect_uri", "error_description": "One or more redirect_uri values are invalid" } -`) - var req1 ClientInformationErrorResponse - require.NoError(t, json.Unmarshal(marshalled1, &req1)) - - marshalled2, err2 := json.Marshal(req1) - require.NoError(t, err2) - - var req3 ClientInformationErrorResponse - require.NoError(t, json.Unmarshal(marshalled2, &req3)) +` + res := ClientInformationErrorResponse{ + Error: ClientInformationErrorResponseErrorCodeInvalidRedirectURI, + ErrorDescription: "One or more redirect_uri values are invalid", + } + marshalled, err := json.Marshal(res) + require.NoError(t, err) - assert.Equal(t, ClientInformationErrorResponseErrorCodeInvalidRedirectURI, req3.Error) - assert.Equal(t, "One or more redirect_uri values are invalid", req3.ErrorDescription) + assert.JSONEq(t, want, string(marshalled)) }) } func TestClientRegistrationResponse(t *testing.T) { // from https://openid.net/specs/openid-connect-registration-1_0.html#RegistrationResponse - t.Run("unmarshal, marshal and unmarshal example", func(t *testing.T) { - marshalled1 := []byte(` + t.Run("marshal example", func(t *testing.T) { + want := ` { "client_id": "s6BhdRkqt3", "client_secret": "ZJYCqe3GGRvdrudKyZS0XhGv_Z45DuKhCUk0gBR1vZk", @@ -375,43 +397,75 @@ func TestClientRegistrationResponse(t *testing.T) { "contacts": ["ve7jtb@example.org", "mary@example.org"], "request_uris": ["https://client.example.org/rf.txt#qpXaRLh_n93TTR9F252ValdatUQvQiJi5BDub2BeznA"] } -`) - var req1 ClientRegistrationResponse - require.NoError(t, json.Unmarshal(marshalled1, &req1)) - - marshalled2, err2 := json.Marshal(req1) - require.NoError(t, err2) - - var req3 ClientRegistrationResponse - require.NoError(t, json.Unmarshal(marshalled2, &req3)) +` + res := ClientRegistrationResponse{ + ClientInformationResponse: ClientInformationResponse{ + ClientMetadata: ClientMetadata{ + RedirectURIs: []string{ + "https://client.example.org/callback", + "https://client.example.org/callback2", + }, + TokenEndpointAuthMethod: AuthMethodBasic, + //GrantTypes: nil, + //ResponseTypes: nil, + ClientName: map[string]string{ + "default": "My Example", + "ja-Jpan-JP": "\u30AF\u30E9\u30A4\u30A2\u30F3\u30C8\u540D", + }, + //ClientURI: nil, + LogoURI: map[string]string{ + "default": "https://client.example.org/logo.png", + }, + //Scope: "", + Contacts: []string{ + "ve7jtb@example.org", + "mary@example.org", + }, + //TOSURI: nil, + //PolicyURI: nil, + JWKSURI: "https://client.example.org/my_public_keys.jwks", + JWKS: jose.JSONWebKeySet{}, + //SoftwareID: "", + //SoftwareVersion: "", + ApplicationType: "web", // cannot use op.ApplicationTypeWeb because of cyclic imports + SectorIdentifierURI: "https://other.example.net/file_of_redirect_uris.json", + SubjectType: "pairwise", + //IDTokenSignedResponseAlg: "", + //IDTokenEncryptedResponseAlg: "", + //IDTokenEncryptedResponseEnc: "", + //UserinfoSignedResponseAlg: "", + UserinfoEncryptedResponseAlg: "RSA-OAEP-256", + UserinfoEncryptedResponseEnc: "A128CBC-HS256", + //RequestObjectSigningAlg: "", + //RequestObjectEncryptionAlg: "", + //RequestObjectEncryptionEnc: "", + //TokenEndpointAuthSigningAlg: "", + //DefaultMaxAge: 0, + //RequireAuthTime: false, + //DefaultACRValues: nil, + //InitiateLoginURI: "", + RequestURIs: []string{ + "https://client.example.org/rf.txt#qpXaRLh_n93TTR9F252ValdatUQvQiJi5BDub2BeznA", + }, + //PostLogoutRedirectURIs: nil, + //ExtraParameters: nil, + }, + ClientID: "s6BhdRkqt3", + ClientSecret: "ZJYCqe3GGRvdrudKyZS0XhGv_Z45DuKhCUk0gBR1vZk", + //ClientIDIssuedAt: 0, + ClientSecretExpiresAt: int64(1577858400), + }, + RegistrationAccessToken: "this.is.an.access.token.value.ffx83", + RegistrationClientURI: "https://server.example.com/connect/register?client_id=s6BhdRkqt3", + } + marshalled, err := json.Marshal(res) + require.NoError(t, err) - assert.Equal(t, "s6BhdRkqt3", req3.ClientID) - assert.Equal(t, "ZJYCqe3GGRvdrudKyZS0XhGv_Z45DuKhCUk0gBR1vZk", req3.ClientSecret) - assert.Equal(t, int64(1577858400), req3.ClientSecretExpiresAt) - assert.Equal(t, "this.is.an.access.token.value.ffx83", req3.RegistrationAccessToken) - assert.Equal(t, "https://server.example.com/connect/register?client_id=s6BhdRkqt3", req3.RegistrationClientURI) - assert.Equal(t, AuthMethodBasic, req3.TokenEndpointAuthMethod) - assert.Equal(t, "web", req3.ApplicationType) // cannot use op.ApplicationTypeWeb because of cyclic imports - assert.Len(t, req3.RedirectURIs, 2) - assert.Contains(t, req3.RedirectURIs, "https://client.example.org/callback") - assert.Contains(t, req3.RedirectURIs, "https://client.example.org/callback2") - assert.Equal(t, "My Example", req3.ClientName["default"]) - assert.Equal(t, "\u30AF\u30E9\u30A4\u30A2\u30F3\u30C8\u540D", req3.ClientName["ja-Jpan-JP"]) - assert.Len(t, req3.LogoURI, 1) - assert.Equal(t, "https://client.example.org/logo.png", req3.LogoURI["default"]) - assert.Equal(t, "pairwise", req3.SubjectType) - assert.Equal(t, "https://other.example.net/file_of_redirect_uris.json", req3.SectorIdentifierURI) - assert.Equal(t, "RSA-OAEP-256", req3.UserinfoEncryptedResponseAlg) - assert.Equal(t, "A128CBC-HS256", req3.UserinfoEncryptedResponseEnc) - assert.Len(t, req3.Contacts, 2) - assert.Contains(t, req3.Contacts, "ve7jtb@example.org") - assert.Contains(t, req3.Contacts, "mary@example.org") - assert.Len(t, req3.RequestURIs, 1) - assert.Contains(t, req3.RequestURIs, "https://client.example.org/rf.txt#qpXaRLh_n93TTR9F252ValdatUQvQiJi5BDub2BeznA") + assert.JSONEq(t, want, string(marshalled)) }) // example from https://www.rfc-editor.org/rfc/rfc7591#page-21 - t.Run("unmarshal example, then marshal, unmarshal again", func(t *testing.T) { - marshalled1 := []byte(` + t.Run("marshal example", func(t *testing.T) { + want := ` { "client_id": "s6BhdRkqt3", "client_secret": "cf136dc3c1fc93f31185e5885805d", @@ -429,41 +483,76 @@ func TestClientRegistrationResponse(t *testing.T) { "jwks_uri": "https://client.example.org/my_public_keys.jwks", "example_extension_parameter": "example_value" } -`) - var req1 ClientRegistrationResponse - require.NoError(t, json.Unmarshal(marshalled1, &req1)) - - marshalled2, err2 := json.Marshal(req1) - require.NoError(t, err2) - - var req3 ClientRegistrationResponse - require.NoError(t, json.Unmarshal(marshalled2, &req3)) +` + res := ClientRegistrationResponse{ + ClientInformationResponse: ClientInformationResponse{ + ClientMetadata: ClientMetadata{ + RedirectURIs: []string{ + "https://client.example.org/callback", + "https://client.example.org/callback2", + }, + TokenEndpointAuthMethod: AuthMethodBasic, + GrantTypes: []GrantType{ + GrantTypeCode, + GrantTypeRefreshToken, + }, + //ResponseTypes: nil, + ClientName: map[string]string{ + "default": "My Example Client", + "ja-Jpan-JP": "\u30AF\u30E9\u30A4\u30A2\u30F3\u30C8\u540D", + }, + //ClientURI: nil, + LogoURI: map[string]string{ + "default": "https://client.example.org/logo.png", + }, + //Scope: "", + //Contacts: nil, + //TOSURI: nil, + //PolicyURI: nil, + JWKSURI: "https://client.example.org/my_public_keys.jwks", + //JWKS: jose.JSONWebKeySet{}, + //SoftwareID: "", + //SoftwareVersion: "", + //ApplicationType: "", + //SectorIdentifierURI: "", + //SubjectType: "", + //IDTokenSignedResponseAlg: "", + //IDTokenEncryptedResponseAlg: "", + //IDTokenEncryptedResponseEnc: "", + //UserinfoSignedResponseAlg: "", + //UserinfoEncryptedResponseAlg: "", + //UserinfoEncryptedResponseEnc: "", + //RequestObjectSigningAlg: "", + //RequestObjectEncryptionAlg: "", + //RequestObjectEncryptionEnc: "", + //TokenEndpointAuthSigningAlg: "", + //DefaultMaxAge: 0, + //RequireAuthTime: false, + //DefaultACRValues: nil, + //InitiateLoginURI: "", + //RequestURIs: nil, + //PostLogoutRedirectURIs: nil, + ExtraParameters: map[string]interface{}{ + "example_extension_parameter": "example_value", + }, + }, + ClientID: "s6BhdRkqt3", + ClientSecret: "cf136dc3c1fc93f31185e5885805d", + ClientIDIssuedAt: int64(2893256800), + ClientSecretExpiresAt: int64(2893276800), + }, + //RegistrationAccessToken: "", + //RegistrationClientURI: "", + } + marshalled, err := json.Marshal(res) + require.NoError(t, err) - assert.Equal(t, "s6BhdRkqt3", req3.ClientID) - assert.Equal(t, "cf136dc3c1fc93f31185e5885805d", req3.ClientSecret) - assert.Equal(t, int64(2893256800), req3.ClientIDIssuedAt) - assert.Equal(t, int64(2893276800), req3.ClientSecretExpiresAt) - assert.Len(t, req3.RedirectURIs, 2) - assert.Contains(t, req3.RedirectURIs, "https://client.example.org/callback") - assert.Contains(t, req3.RedirectURIs, "https://client.example.org/callback2") - assert.Len(t, req3.GrantTypes, 2) - assert.Contains(t, req3.GrantTypes, GrantTypeCode) - assert.Contains(t, req3.GrantTypes, GrantTypeRefreshToken) - assert.Len(t, req3.ClientName, 2) - assert.Equal(t, "My Example Client", req3.ClientName["default"]) - assert.Equal(t, "\u30AF\u30E9\u30A4\u30A2\u30F3\u30C8\u540D", req3.ClientName["ja-Jpan-JP"]) - assert.Equal(t, AuthMethodBasic, req3.TokenEndpointAuthMethod) - assert.Len(t, req3.LogoURI, 1) - assert.Equal(t, "https://client.example.org/logo.png", req3.LogoURI["default"]) - assert.Equal(t, "https://client.example.org/my_public_keys.jwks", req3.JWKSURI) - assert.Len(t, req3.ExtraParameters, 1) - assert.Contains(t, req3.ExtraParameters, "example_extension_parameter") - assert.Equal(t, "example_value", req3.ExtraParameters["example_extension_parameter"]) + assert.JSONEq(t, want, string(marshalled)) }) // example from https://www.rfc-editor.org/rfc/rfc7592.html#page-11 - t.Run("unmarshal example, then marshal, unmarshal again", func(t *testing.T) { - marshalled1 := []byte(` + t.Run("marshal example", func(t *testing.T) { + want := ` { "registration_access_token": "reg-23410913-abewfq.123483", "registration_client_uri": "https://server.example.com/register/s6BhdRkqt3", @@ -482,36 +571,69 @@ func TestClientRegistrationResponse(t *testing.T) { "logo_uri": "https://client.example.org/logo.png", "jwks_uri": "https://client.example.org/my_public_keys.jwks" } -`) - var req ClientRegistrationResponse - require.NoError(t, json.Unmarshal(marshalled1, &req)) - - var req1 ClientRegistrationResponse - require.NoError(t, json.Unmarshal(marshalled1, &req1)) - - marshalled2, err2 := json.Marshal(req1) - require.NoError(t, err2) - - var req3 ClientRegistrationResponse - require.NoError(t, json.Unmarshal(marshalled2, &req3)) +` + res := ClientRegistrationResponse{ + ClientInformationResponse: ClientInformationResponse{ + ClientMetadata: ClientMetadata{ + RedirectURIs: []string{ + "https://client.example.org/callback", + "https://client.example.org/callback2", + }, + TokenEndpointAuthMethod: AuthMethodBasic, + GrantTypes: []GrantType{ + GrantTypeCode, + GrantTypeRefreshToken, + }, + ResponseTypes: nil, + ClientName: map[string]string{ + "default": "My Example Client", + "ja-Jpan-JP": "\u30AF\u30E9\u30A4\u30A2\u30F3\u30C8\u540D", + }, + //ClientURI: nil, + LogoURI: map[string]string{ + "default": "https://client.example.org/logo.png", + }, + //Scope: "", + //Contacts: nil, + //TOSURI: nil, + //PolicyURI: nil, + JWKSURI: "https://client.example.org/my_public_keys.jwks", + //JWKS: jose.JSONWebKeySet{}, + //SoftwareID: "", + //SoftwareVersion: "", + //ApplicationType: "", + //SectorIdentifierURI: "", + //SubjectType: "", + //IDTokenSignedResponseAlg: "", + //IDTokenEncryptedResponseAlg: "", + //IDTokenEncryptedResponseEnc: "", + //UserinfoSignedResponseAlg: "", + //UserinfoEncryptedResponseAlg: "", + //UserinfoEncryptedResponseEnc: "", + //RequestObjectSigningAlg: "", + //RequestObjectEncryptionAlg: "", + //RequestObjectEncryptionEnc: "", + //TokenEndpointAuthSigningAlg: "", + //DefaultMaxAge: 0, + //RequireAuthTime: false, + //DefaultACRValues: nil, + //InitiateLoginURI: "", + //RequestURIs: nil, + //PostLogoutRedirectURIs: nil, + //ExtraParameters: nil, + }, + ClientID: "s6BhdRkqt3", + ClientSecret: "cf136dc3c1fc93f31185e5885805d", + ClientIDIssuedAt: int64(2893256800), + ClientSecretExpiresAt: int64(2893276800), + }, + RegistrationAccessToken: "reg-23410913-abewfq.123483", + RegistrationClientURI: "https://server.example.com/register/s6BhdRkqt3", + } + marshalled, err := json.Marshal(res) + require.NoError(t, err) - //assert.Equal(t, "reg-23410913-abewfq.123483", req3.RegistrationAccessToken) - //assert.Equal(t, "https://server.example.com/register/s6BhdRkqt3", req3.RegistrationClientURI) - assert.Equal(t, "s6BhdRkqt3", req3.ClientID) - assert.Equal(t, int64(2893256800), req3.ClientIDIssuedAt) - assert.Equal(t, int64(2893276800), req3.ClientSecretExpiresAt) - assert.Len(t, req3.ClientName, 2) - assert.Equal(t, "My Example Client", req3.ClientName["default"]) - assert.Equal(t, "\u30AF\u30E9\u30A4\u30A2\u30F3\u30C8\u540D", req3.ClientName["ja-Jpan-JP"]) - assert.Len(t, req3.RedirectURIs, 2) - assert.Contains(t, req3.RedirectURIs, "https://client.example.org/callback") - assert.Contains(t, req3.RedirectURIs, "https://client.example.org/callback2") - assert.Len(t, req3.GrantTypes, 2) - assert.Contains(t, req3.GrantTypes, GrantTypeCode) - assert.Contains(t, req3.GrantTypes, GrantTypeRefreshToken) - assert.Len(t, req3.LogoURI, 1) - assert.Equal(t, "https://client.example.org/logo.png", req3.LogoURI["default"]) - assert.Equal(t, "https://client.example.org/my_public_keys.jwks", req3.JWKSURI) + assert.JSONEq(t, want, string(marshalled)) }) } From 69ce55b109abe78c6fb89e254e40df28c7f90647 Mon Sep 17 00:00:00 2001 From: mqf20 Date: Sun, 10 Aug 2025 14:03:01 +0800 Subject: [PATCH 35/44] WIP added InternationalizedField Signed-off-by: mqf20 --- pkg/internationalizedfield/internationalized_field.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/internationalizedfield/internationalized_field.go b/pkg/internationalizedfield/internationalized_field.go index b62b2a33..b48ded7d 100644 --- a/pkg/internationalizedfield/internationalized_field.go +++ b/pkg/internationalizedfield/internationalized_field.go @@ -32,8 +32,8 @@ type InternationalizedField struct { Items languageMap } -func New(fieldName string) *InternationalizedField { - return &InternationalizedField{ +func New(fieldName string) InternationalizedField { + return InternationalizedField{ fieldName: fieldName, Items: make(languageMap), } @@ -44,7 +44,7 @@ func (i *InternationalizedField) UnmarshalJSON(data []byte) error { var raw map[string]interface{} if err := json.Unmarshal(data, &raw); err != nil { - return fmt.Errorf("could not unmarshal raw data: %w", err) + return fmt.Errorf("failed to unmarshal raw data: %w", err) } for key, value := range raw { @@ -62,7 +62,7 @@ func (i *InternationalizedField) UnmarshalJSON(data []byte) error { if len(parts) == 2 { langTag := parts[1] if t, err := language.Parse(langTag); err != nil { - return fmt.Errorf("could not parse language tag %q: %w", langTag, err) + return fmt.Errorf("failed to parse language tag %q: %w", langTag, err) } else { i.Items[t] = name } From 1ccb1e8f370982e3e51fb0f6492712d671bec13d Mon Sep 17 00:00:00 2001 From: mqf20 Date: Sun, 10 Aug 2025 14:10:20 +0800 Subject: [PATCH 36/44] WIP improved InternationalizedField Signed-off-by: mqf20 --- .../internationalized_field.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pkg/internationalizedfield/internationalized_field.go b/pkg/internationalizedfield/internationalized_field.go index b48ded7d..8ec65c1c 100644 --- a/pkg/internationalizedfield/internationalized_field.go +++ b/pkg/internationalizedfield/internationalized_field.go @@ -28,13 +28,13 @@ type languageMap = map[language.Tag]string // // [Human-Readable Client Metadata]: https://www.rfc-editor.org/rfc/rfc7591#section-2.2 type InternationalizedField struct { - fieldName string + FieldName string Items languageMap } func New(fieldName string) InternationalizedField { return InternationalizedField{ - fieldName: fieldName, + FieldName: fieldName, Items: make(languageMap), } } @@ -49,12 +49,12 @@ func (i *InternationalizedField) UnmarshalJSON(data []byte) error { for key, value := range raw { switch { - case key == i.fieldName: + case key == i.FieldName: if name, ok := value.(string); ok { // This is the default, non-tagged name. i.Items[language.Und] = name } - case strings.HasPrefix(key, i.fieldName+"#"): + case strings.HasPrefix(key, i.FieldName+"#"): if name, ok := value.(string); ok { // This is a tagged name, e.g., "client_name#ja-Jpan-JP" // Split the key at the first '#' to get the language tag. @@ -77,9 +77,9 @@ func (i InternationalizedField) MarshalJSON() ([]byte, error) { if len(i.Items) > 0 { for lang, name := range i.Items { if lang == language.Und { - res[i.fieldName] = name + res[i.FieldName] = name } else { - res[fmt.Sprintf("%s#%s", i.fieldName, lang)] = name + res[fmt.Sprintf("%s#%s", i.FieldName, lang)] = name } } } From 82203a118f24bb87d8d96279769722be924df12e Mon Sep 17 00:00:00 2001 From: mqf20 Date: Sun, 10 Aug 2025 14:44:39 +0800 Subject: [PATCH 37/44] WIP improved parsing Signed-off-by: mqf20 --- pkg/oidc/dynamic_client_registration.go | 306 ++++++++++++------------ 1 file changed, 150 insertions(+), 156 deletions(-) diff --git a/pkg/oidc/dynamic_client_registration.go b/pkg/oidc/dynamic_client_registration.go index 29b68a30..4a19a211 100644 --- a/pkg/oidc/dynamic_client_registration.go +++ b/pkg/oidc/dynamic_client_registration.go @@ -503,247 +503,241 @@ func (c *ClientMetadata) UnmarshalJSON(data []byte) error { c.ExtraParameters = make(map[string]interface{}) // Unmarshal into a temporary map to inspect all keys. - var raw map[string]interface{} - if err := json.Unmarshal(data, &raw); err != nil { + var rawMap map[string]json.RawMessage + if err := json.Unmarshal(data, &rawMap); err != nil { return fmt.Errorf("could not unmarshal raw data: %w", err) } // Iterate over all keys found in the JSON. - for key, value := range raw { + for key, value := range rawMap { switch { case key == "redirect_uris": - if uris, ok := value.([]interface{}); ok { - for _, u := range uris { - if uriStr, ok := u.(string); ok { - c.RedirectURIs = append(c.RedirectURIs, uriStr) - } - } + if err := json.Unmarshal(value, &c.RedirectURIs); err != nil { + return err } case key == "token_endpoint_auth_method": - if vStr, ok := value.(string); ok { - if v, exists := AuthMethodMap[vStr]; exists { - c.TokenEndpointAuthMethod = v - } + if err := json.Unmarshal(value, &c.TokenEndpointAuthMethod); err != nil { + // should we check against AuthMethodMap if token_endpoint_auth_method is valid? + return err } case key == "grant_types": - if gts, ok := value.([]interface{}); ok { - for _, gt := range gts { - if gtStr, ok := gt.(string); ok { - if gtParsed, exists := GrantTypeMap[gtStr]; exists { - c.GrantTypes = append(c.GrantTypes, gtParsed) - } - } - } + if err := json.Unmarshal(value, &c.GrantTypes); err != nil { + // should we check against GrantTypeMap if grant_types is valid? + return err } case key == "response_types": - if rts, ok := value.([]interface{}); ok { - for _, rt := range rts { - if rtStr, ok := rt.(string); ok { - if rtParsed, exists := ResponseTypeMap[rtStr]; exists { - c.ResponseTypes = append(c.ResponseTypes, rtParsed) - } - } - } + if err := json.Unmarshal(value, &c.ResponseTypes); err != nil { + // should we check against ResponseTypeMap if response_types is valid? + return err } case key == "client_name": - if name, ok := value.(string); ok { - // This is the default, non-tagged name. - c.ClientName["default"] = name + var name string + if err := json.Unmarshal(value, &name); err != nil { + return err } + c.ClientName["default"] = name case strings.HasPrefix(key, "client_name#"): - if name, ok := value.(string); ok { - // This is a tagged name, e.g., "client_name#ja-Jpan-JP" - // Split the key at the first '#' to get the language tag. - parts := strings.SplitN(key, "#", 2) - if len(parts) == 2 { - langTag := parts[1] - c.ClientName[langTag] = name - } + var name string + if err := json.Unmarshal(value, &name); err != nil { + return err + } + // This is a tagged name, e.g., "client_name#ja-Jpan-JP" + // Split the key at the first '#' to get the language tag. + parts := strings.SplitN(key, "#", 2) + if len(parts) == 2 { + langTag := parts[1] + c.ClientName[langTag] = name + } else { + return fmt.Errorf("invalid client_name format: %q", key) } case key == "client_uri": - if uri, ok := value.(string); ok { - // This is the default, non-tagged name. - c.ClientURI["default"] = uri + var uri string + if err := json.Unmarshal(value, &uri); err != nil { + return err } + c.ClientURI["default"] = uri case strings.HasPrefix(key, "client_uri#"): - if uri, ok := value.(string); ok { - // This is a tagged name, e.g., "client_uri#ja-Jpan-JP" - // Split the key at the first '#' to get the language tag. - parts := strings.SplitN(key, "#", 2) - if len(parts) == 2 { - langTag := parts[1] - c.ClientURI[langTag] = uri - } + var uri string + if err := json.Unmarshal(value, &uri); err != nil { + return err + } + // This is a tagged name, e.g., "client_uri#ja-Jpan-JP" + // Split the key at the first '#' to get the language tag. + parts := strings.SplitN(key, "#", 2) + if len(parts) == 2 { + langTag := parts[1] + c.ClientURI[langTag] = uri + } else { + return fmt.Errorf("invalid client_uri format: %q", key) } case key == "logo_uri": - if logo, ok := value.(string); ok { - // This is the default, non-tagged name. - c.LogoURI["default"] = logo + var uri string + if err := json.Unmarshal(value, &uri); err != nil { + return err } + c.LogoURI["default"] = uri case strings.HasPrefix(key, "logo_uri#"): - if logo, ok := value.(string); ok { - // This is a tagged name, e.g., "logo_uri#ja-Jpan-JP" - // Split the key at the first '#' to get the language tag. - parts := strings.SplitN(key, "#", 2) - if len(parts) == 2 { - langTag := parts[1] - c.LogoURI[langTag] = logo - } + var uri string + if err := json.Unmarshal(value, &uri); err != nil { + return err + } + // This is a tagged name, e.g., "logo_uri#ja-Jpan-JP" + // Split the key at the first '#' to get the language tag. + parts := strings.SplitN(key, "#", 2) + if len(parts) == 2 { + langTag := parts[1] + c.LogoURI[langTag] = uri + } else { + return fmt.Errorf("invalid logo_uri format: %q", key) } case key == "scope": - if v, ok := value.(string); ok { - c.Scope = v + if err := json.Unmarshal(value, &c.Scope); err != nil { + return err } case key == "contacts": - if cts, ok := value.([]interface{}); ok { - for _, ct := range cts { - if ctStr, ok := ct.(string); ok { - c.Contacts = append(c.Contacts, ctStr) - } - } + if err := json.Unmarshal(value, &c.Contacts); err != nil { + return err } case key == "tos_uri": - if uri, ok := value.(string); ok { - // This is the default, non-tagged name. - c.TOSURI["default"] = uri + var uri string + if err := json.Unmarshal(value, &uri); err != nil { + return err } + c.TOSURI["default"] = uri case strings.HasPrefix(key, "tos_uri#"): - if uri, ok := value.(string); ok { - // This is a tagged name, e.g., "tos_uri#ja-Jpan-JP" - // Split the key at the first '#' to get the language tag. - parts := strings.SplitN(key, "#", 2) - if len(parts) == 2 { - langTag := parts[1] - c.TOSURI[langTag] = uri - } + var uri string + if err := json.Unmarshal(value, &uri); err != nil { + return err + } + // This is a tagged name, e.g., "tos_uri#ja-Jpan-JP" + // Split the key at the first '#' to get the language tag. + parts := strings.SplitN(key, "#", 2) + if len(parts) == 2 { + langTag := parts[1] + c.TOSURI[langTag] = uri + } else { + return fmt.Errorf("invalid client_uri format: %q", key) } case key == "policy_uri": - if uri, ok := value.(string); ok { - // This is the default, non-tagged name. - c.PolicyURI["default"] = uri + var uri string + if err := json.Unmarshal(value, &uri); err != nil { + return err } + c.PolicyURI["default"] = uri case strings.HasPrefix(key, "policy_uri#"): - if uri, ok := value.(string); ok { - // This is a tagged name, e.g., "policy_uri#ja-Jpan-JP" - // Split the key at the first '#' to get the language tag. - parts := strings.SplitN(key, "#", 2) - if len(parts) == 2 { - langTag := parts[1] - c.PolicyURI[langTag] = uri - } + var uri string + if err := json.Unmarshal(value, &uri); err != nil { + return err + } + c.LogoURI["default"] = uri + // This is a tagged name, e.g., "policy_uri#ja-Jpan-JP" + // Split the key at the first '#' to get the language tag. + parts := strings.SplitN(key, "#", 2) + if len(parts) == 2 { + langTag := parts[1] + c.PolicyURI[langTag] = uri + } else { + return fmt.Errorf("invalid client_uri format: %q", key) } case key == "jwks_uri": - if v, ok := value.(string); ok { - c.JWKSURI = v + if err := json.Unmarshal(value, &c.JWKSURI); err != nil { + return err } case key == "jwks": - // unmarshal into a jose.JSONWebKeySet - if vBytes, err := json.Marshal(value); err == nil { - _ = json.Unmarshal(vBytes, &c.JWKS) + if err := json.Unmarshal(value, &c.JWKS); err != nil { + return err } case key == "software_id": - if v, ok := value.(string); ok { - c.SoftwareID = v + if err := json.Unmarshal(value, &c.SoftwareID); err != nil { + return err } case key == "software_version": - if v, ok := value.(string); ok { - c.SoftwareVersion = v + if err := json.Unmarshal(value, &c.SoftwareVersion); err != nil { + return err } - //case key == "software_statement": - // if v, ok := value.(string); ok { - // c.SoftwareStatement = v - // } case key == "application_type": - if v, ok := value.(string); ok { - c.ApplicationType = v + if err := json.Unmarshal(value, &c.ApplicationType); err != nil { + return err } case key == "sector_identifier_uri": - if v, ok := value.(string); ok { - c.SectorIdentifierURI = v + if err := json.Unmarshal(value, &c.SectorIdentifierURI); err != nil { + return err } case key == "subject_type": - if v, ok := value.(string); ok { - c.SubjectType = v + if err := json.Unmarshal(value, &c.SubjectType); err != nil { + return err } case key == "id_token_signed_response_alg": - if v, ok := value.(string); ok { - c.IDTokenSignedResponseAlg = v + if err := json.Unmarshal(value, &c.IDTokenSignedResponseAlg); err != nil { + return err } case key == "id_token_encrypted_response_alg": - if v, ok := value.(string); ok { - c.IDTokenEncryptedResponseAlg = v + if err := json.Unmarshal(value, &c.IDTokenEncryptedResponseAlg); err != nil { + return err } case key == "id_token_encrypted_response_enc": - if v, ok := value.(string); ok { - c.IDTokenEncryptedResponseEnc = v + if err := json.Unmarshal(value, &c.IDTokenEncryptedResponseEnc); err != nil { + return err } case key == "userinfo_signed_response_alg": - if v, ok := value.(string); ok { - c.UserinfoSignedResponseAlg = v + if err := json.Unmarshal(value, &c.UserinfoSignedResponseAlg); err != nil { + return err } case key == "userinfo_encrypted_response_alg": - if v, ok := value.(string); ok { - c.UserinfoEncryptedResponseAlg = v + if err := json.Unmarshal(value, &c.UserinfoEncryptedResponseAlg); err != nil { + return err } case key == "userinfo_encrypted_response_enc": - if v, ok := value.(string); ok { - c.UserinfoEncryptedResponseEnc = v + if err := json.Unmarshal(value, &c.UserinfoEncryptedResponseEnc); err != nil { + return err } case key == "request_object_signing_alg": - if v, ok := value.(string); ok { - c.RequestObjectSigningAlg = v + if err := json.Unmarshal(value, &c.RequestObjectEncryptionAlg); err != nil { + return err } case key == "request_object_encryption_alg": - if v, ok := value.(string); ok { - c.RequestObjectEncryptionAlg = v + if err := json.Unmarshal(value, &c.RequestObjectEncryptionAlg); err != nil { + return err } case key == "request_object_encryption_enc": - if v, ok := value.(string); ok { - c.RequestObjectEncryptionEnc = v + if err := json.Unmarshal(value, &c.RequestObjectEncryptionEnc); err != nil { + return err } case key == "token_endpoint_auth_signing_alg": - if v, ok := value.(string); ok { - c.TokenEndpointAuthSigningAlg = v + if err := json.Unmarshal(value, &c.TokenEndpointAuthSigningAlg); err != nil { + return err } case key == "default_max_age": - if v, ok := value.(float64); ok { - c.DefaultMaxAge = int(v) + if err := json.Unmarshal(value, &c.DefaultMaxAge); err != nil { + return err } case key == "require_auth_time": - if v, ok := value.(bool); ok { - c.RequireAuthTime = v + if err := json.Unmarshal(value, &c.RequireAuthTime); err != nil { + return err } case key == "default_acr_values": - if acrs, ok := value.([]interface{}); ok { - for _, acr := range acrs { - if acrStr, ok := acr.(string); ok { - c.DefaultACRValues = append(c.DefaultACRValues, acrStr) - } - } + if err := json.Unmarshal(value, &c.DefaultACRValues); err != nil { + return err } case key == "initiate_login_uri": - if v, ok := value.(string); ok { - c.InitiateLoginURI = v + if err := json.Unmarshal(value, &c.InitiateLoginURI); err != nil { + return err } case key == "request_uris": - if uris, ok := value.([]interface{}); ok { - for _, uri := range uris { - if uriStr, ok := uri.(string); ok { - c.RequestURIs = append(c.RequestURIs, uriStr) - } - } + if err := json.Unmarshal(value, &c.RequestURIs); err != nil { + return err } case key == "post_logout_redirect_uris": - if uris, ok := value.([]interface{}); ok { - for _, uri := range uris { - if uriStr, ok := uri.(string); ok { - c.PostLogoutRedirectURIs = append(c.PostLogoutRedirectURIs, uriStr) - } - } + if err := json.Unmarshal(value, &c.PostLogoutRedirectURIs); err != nil { + return err } default: // If the key didn't match any of the above, it's an extra parameter. - c.ExtraParameters[key] = value + var val interface{} + if err := json.Unmarshal(value, &val); err != nil { + return err + } + c.ExtraParameters[key] = val } } From e72fd65f5e46ba138494f12f2b6a2e6cab638436 Mon Sep 17 00:00:00 2001 From: mqf20 Date: Sun, 10 Aug 2025 15:52:53 +0800 Subject: [PATCH 38/44] WIP updated to use internationalizedfield Signed-off-by: mqf20 --- example/server/storage/storage.go | 23 +++- pkg/oidc/dynamic_client_registration.go | 118 +++++++++++-------- pkg/oidc/dynamic_client_registration_test.go | 116 +++++++++++------- 3 files changed, 161 insertions(+), 96 deletions(-) diff --git a/example/server/storage/storage.go b/example/server/storage/storage.go index 1a97f4ff..fd1edc21 100644 --- a/example/server/storage/storage.go +++ b/example/server/storage/storage.go @@ -6,6 +6,8 @@ import ( "crypto/rsa" "errors" "fmt" + "github.com/zitadel/oidc/v3/pkg/internationalizedfield" + "golang.org/x/text/language" "math/big" "strings" "sync" @@ -962,7 +964,12 @@ func (s *Storage) RegisterClient(_ context.Context, c *oidc.ClientRegistrationRe TokenEndpointAuthMethod: client.authMethod, GrantTypes: client.grantTypes, ResponseTypes: client.responseTypes, - ClientName: map[string]string{"default": client.id}, + ClientName: internationalizedfield.InternationalizedField{ + FieldName: "client_name", + Items: map[language.Tag]string{ + language.Und: client.id, + }, + }, //ClientURI: nil, //LogoURI: nil, //Scope: "", @@ -1019,7 +1026,12 @@ func (s *Storage) ReadClient(_ context.Context, clientID string) (*oidc.ClientRe TokenEndpointAuthMethod: client.authMethod, GrantTypes: client.grantTypes, ResponseTypes: client.responseTypes, - ClientName: map[string]string{"default": client.id}, + ClientName: internationalizedfield.InternationalizedField{ + FieldName: "client_name", + Items: map[language.Tag]string{ + language.Und: client.id, + }, + }, //ClientURI: nil, //LogoURI: nil, //Scope: "", @@ -1081,7 +1093,12 @@ func (s *Storage) UpdateClient(_ context.Context, c *oidc.ClientUpdateRequest) ( TokenEndpointAuthMethod: client.authMethod, GrantTypes: client.grantTypes, ResponseTypes: client.responseTypes, - ClientName: map[string]string{"default": client.id}, + ClientName: internationalizedfield.InternationalizedField{ + FieldName: "client_name", + Items: map[language.Tag]string{ + language.Und: client.id, + }, + }, //ClientURI: nil, //LogoURI: nil, //Scope: "", diff --git a/pkg/oidc/dynamic_client_registration.go b/pkg/oidc/dynamic_client_registration.go index 4a19a211..ce46febb 100644 --- a/pkg/oidc/dynamic_client_registration.go +++ b/pkg/oidc/dynamic_client_registration.go @@ -5,6 +5,8 @@ import ( "errors" "fmt" "github.com/go-jose/go-jose/v4" + "github.com/zitadel/oidc/v3/pkg/internationalizedfield" + "golang.org/x/text/language" "strings" ) @@ -129,7 +131,7 @@ type ClientMetadata struct { // [Section 2.2]. // // [Section 2.2]: https://www.rfc-editor.org/rfc/rfc7591#section-2.2 - ClientName map[string]string `json:"client_name"` + ClientName internationalizedfield.InternationalizedField `json:"client_name"` // ClientURI is a URL string of a web page providing information about the client. // If present, the server SHOULD display this URL to the end-user in @@ -139,7 +141,7 @@ type ClientMetadata struct { // described in [Section 2.2]. // // [Section 2.2]: https://www.rfc-editor.org/rfc/rfc7591#section-2.2 - ClientURI map[string]string `json:"client_uri"` + ClientURI internationalizedfield.InternationalizedField `json:"client_uri"` // LogoURI is a URL string that references a logo for the client. If present, the // server SHOULD display this image to the end-user during approval. @@ -148,7 +150,7 @@ type ClientMetadata struct { // [Section 2.2]. // // [Section 2.2]: https://www.rfc-editor.org/rfc/rfc7591#section-2.2 - LogoURI map[string]string `json:"logo_uri"` + LogoURI internationalizedfield.InternationalizedField `json:"logo_uri"` // Scope is a string containing a space-separated list of scope values (as // described in [Section 3.3] of OAuth 2.0 [RFC6749]) that the client @@ -178,7 +180,7 @@ type ClientMetadata struct { // be internationalized, as described in [Section 2.2]. // // [Section 2.2]: https://www.rfc-editor.org/rfc/rfc7591#section-2.2 - TOSURI map[string]string `json:"tos_uri"` + TOSURI internationalizedfield.InternationalizedField `json:"tos_uri"` // PolicyURI is a URL string that points to a human-readable privacy policy document // that describes how the deployment organization collects, uses, @@ -188,7 +190,7 @@ type ClientMetadata struct { // this field MAY be internationalized, as described in [Section 2.2]. // // [Section 2.2]: https://www.rfc-editor.org/rfc/rfc7591#section-2.2 - PolicyURI map[string]string `json:"policy_uri"` + PolicyURI internationalizedfield.InternationalizedField `json:"policy_uri"` // JWKSURI is a URL string referencing the client's JSON Web Key (JWK) Set // [RFC7517] document, which contains the client's public keys. The @@ -495,11 +497,11 @@ type ClientMetadata struct { func (c *ClientMetadata) UnmarshalJSON(data []byte) error { // Initialize maps to avoid nil pointer issues later. - c.ClientName = make(map[string]string) - c.ClientURI = make(map[string]string) - c.LogoURI = make(map[string]string) - c.TOSURI = make(map[string]string) - c.PolicyURI = make(map[string]string) + c.ClientName = internationalizedfield.New("client_name") + c.ClientURI = internationalizedfield.New("client_uri") + c.LogoURI = internationalizedfield.New("logo_uri") + c.TOSURI = internationalizedfield.New("tos_uri") + c.PolicyURI = internationalizedfield.New("policy_uri") c.ExtraParameters = make(map[string]interface{}) // Unmarshal into a temporary map to inspect all keys. @@ -535,7 +537,7 @@ func (c *ClientMetadata) UnmarshalJSON(data []byte) error { if err := json.Unmarshal(value, &name); err != nil { return err } - c.ClientName["default"] = name + c.ClientName.Items[language.Und] = name case strings.HasPrefix(key, "client_name#"): var name string if err := json.Unmarshal(value, &name); err != nil { @@ -545,8 +547,11 @@ func (c *ClientMetadata) UnmarshalJSON(data []byte) error { // Split the key at the first '#' to get the language tag. parts := strings.SplitN(key, "#", 2) if len(parts) == 2 { - langTag := parts[1] - c.ClientName[langTag] = name + langTag, err := language.Parse(parts[1]) + if err != nil { + return fmt.Errorf("failed to parse language tag for client_name: %w", err) + } + c.ClientName.Items[langTag] = name } else { return fmt.Errorf("invalid client_name format: %q", key) } @@ -555,7 +560,7 @@ func (c *ClientMetadata) UnmarshalJSON(data []byte) error { if err := json.Unmarshal(value, &uri); err != nil { return err } - c.ClientURI["default"] = uri + c.ClientURI.Items[language.Und] = uri case strings.HasPrefix(key, "client_uri#"): var uri string if err := json.Unmarshal(value, &uri); err != nil { @@ -565,8 +570,11 @@ func (c *ClientMetadata) UnmarshalJSON(data []byte) error { // Split the key at the first '#' to get the language tag. parts := strings.SplitN(key, "#", 2) if len(parts) == 2 { - langTag := parts[1] - c.ClientURI[langTag] = uri + langTag, err := language.Parse(parts[1]) + if err != nil { + return fmt.Errorf("failed to parse language tag for client_uri: %w", err) + } + c.ClientURI.Items[langTag] = uri } else { return fmt.Errorf("invalid client_uri format: %q", key) } @@ -575,7 +583,7 @@ func (c *ClientMetadata) UnmarshalJSON(data []byte) error { if err := json.Unmarshal(value, &uri); err != nil { return err } - c.LogoURI["default"] = uri + c.LogoURI.Items[language.Und] = uri case strings.HasPrefix(key, "logo_uri#"): var uri string if err := json.Unmarshal(value, &uri); err != nil { @@ -585,8 +593,11 @@ func (c *ClientMetadata) UnmarshalJSON(data []byte) error { // Split the key at the first '#' to get the language tag. parts := strings.SplitN(key, "#", 2) if len(parts) == 2 { - langTag := parts[1] - c.LogoURI[langTag] = uri + langTag, err := language.Parse(parts[1]) + if err != nil { + return fmt.Errorf("failed to parse language tag for logo_uri: %w", err) + } + c.LogoURI.Items[langTag] = uri } else { return fmt.Errorf("invalid logo_uri format: %q", key) } @@ -603,7 +614,7 @@ func (c *ClientMetadata) UnmarshalJSON(data []byte) error { if err := json.Unmarshal(value, &uri); err != nil { return err } - c.TOSURI["default"] = uri + c.TOSURI.Items[language.Und] = uri case strings.HasPrefix(key, "tos_uri#"): var uri string if err := json.Unmarshal(value, &uri); err != nil { @@ -613,8 +624,11 @@ func (c *ClientMetadata) UnmarshalJSON(data []byte) error { // Split the key at the first '#' to get the language tag. parts := strings.SplitN(key, "#", 2) if len(parts) == 2 { - langTag := parts[1] - c.TOSURI[langTag] = uri + langTag, err := language.Parse(parts[1]) + if err != nil { + return fmt.Errorf("failed to parse language tag for tos_uri: %w", err) + } + c.TOSURI.Items[langTag] = uri } else { return fmt.Errorf("invalid client_uri format: %q", key) } @@ -623,19 +637,21 @@ func (c *ClientMetadata) UnmarshalJSON(data []byte) error { if err := json.Unmarshal(value, &uri); err != nil { return err } - c.PolicyURI["default"] = uri + c.PolicyURI.Items[language.Und] = uri case strings.HasPrefix(key, "policy_uri#"): var uri string if err := json.Unmarshal(value, &uri); err != nil { return err } - c.LogoURI["default"] = uri // This is a tagged name, e.g., "policy_uri#ja-Jpan-JP" // Split the key at the first '#' to get the language tag. parts := strings.SplitN(key, "#", 2) if len(parts) == 2 { - langTag := parts[1] - c.PolicyURI[langTag] = uri + langTag, err := language.Parse(parts[1]) + if err != nil { + return fmt.Errorf("failed to parse language tag for policy_uri: %w", err) + } + c.PolicyURI.Items[langTag] = uri } else { return fmt.Errorf("invalid client_uri format: %q", key) } @@ -794,32 +810,32 @@ func (c ClientMetadata) MarshalJSON() ([]byte, error) { res["response_types"] = c.ResponseTypes } - if len(c.ClientName) > 0 { - for lang, name := range c.ClientName { - if lang == "default" { - res["client_name"] = name + if len(c.ClientName.Items) > 0 { + for lang, name := range c.ClientName.Items { + if lang == language.Und { + res[c.ClientName.FieldName] = name } else { - res[fmt.Sprintf("client_name#%s", lang)] = name + res[fmt.Sprintf("%s#%s", c.ClientName.FieldName, lang)] = name } } } - if len(c.ClientURI) > 0 { - for lang, uri := range c.ClientURI { - if lang == "default" { - res["client_uri"] = uri + if len(c.ClientURI.Items) > 0 { + for lang, uri := range c.ClientURI.Items { + if lang == language.Und { + res[c.ClientURI.FieldName] = uri } else { - res[fmt.Sprintf("client_uri#%s", lang)] = uri + res[fmt.Sprintf("%s#%s", c.ClientURI.FieldName, lang)] = uri } } } - if len(c.LogoURI) > 0 { - for lang, logo := range c.LogoURI { - if lang == "default" { - res["logo_uri"] = logo + if len(c.LogoURI.Items) > 0 { + for lang, logo := range c.LogoURI.Items { + if lang == language.Und { + res[c.LogoURI.FieldName] = logo } else { - res[fmt.Sprintf("logo_uri#%s", lang)] = logo + res[fmt.Sprintf("%s#%s", c.LogoURI.FieldName, lang)] = logo } } } @@ -832,22 +848,22 @@ func (c ClientMetadata) MarshalJSON() ([]byte, error) { res["contacts"] = c.Contacts } - if len(c.TOSURI) > 0 { - for lang, uri := range c.TOSURI { - if lang == "default" { - res["tos_uri"] = uri + if len(c.TOSURI.Items) > 0 { + for lang, uri := range c.TOSURI.Items { + if lang == language.Und { + res[c.TOSURI.FieldName] = uri } else { - res[fmt.Sprintf("tos_uri#%s", lang)] = uri + res[fmt.Sprintf("%s#%s", c.TOSURI.FieldName, lang)] = uri } } } - if len(c.PolicyURI) > 0 { - for lang, uri := range c.PolicyURI { - if lang == "default" { - res["policy_uri"] = uri + if len(c.PolicyURI.Items) > 0 { + for lang, uri := range c.PolicyURI.Items { + if lang == language.Und { + res[c.PolicyURI.FieldName] = uri } else { - res[fmt.Sprintf("policy_uri#%s", lang)] = uri + res[fmt.Sprintf("%s#%s", c.PolicyURI.FieldName, lang)] = uri } } } diff --git a/pkg/oidc/dynamic_client_registration_test.go b/pkg/oidc/dynamic_client_registration_test.go index b689ec78..9efd65ce 100644 --- a/pkg/oidc/dynamic_client_registration_test.go +++ b/pkg/oidc/dynamic_client_registration_test.go @@ -7,6 +7,8 @@ import ( "github.com/go-jose/go-jose/v4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/zitadel/oidc/v3/pkg/internationalizedfield" + "golang.org/x/text/language" "math/big" "testing" ) @@ -35,6 +37,8 @@ func compareRSAJSONWebKey( } func TestClientRegistrationRequest(t *testing.T) { + wantJPTag, err := language.Parse("ja-Jpan-JP") + require.NoError(t, err) t.Run("test grant types", func(t *testing.T) { marshalled := []byte(` { @@ -99,12 +103,12 @@ func TestClientRegistrationRequest(t *testing.T) { assert.Len(t, req.RedirectURIs, 2) assert.Contains(t, req.RedirectURIs, "https://client.example.org/callback") assert.Contains(t, req.RedirectURIs, "https://client.example.org/callback2") - assert.Len(t, req.ClientName, 2) - assert.Equal(t, "My Example Client", req.ClientName["default"]) - assert.Equal(t, "\u30AF\u30E9\u30A4\u30A2\u30F3\u30C8\u540D", req.ClientName["ja-Jpan-JP"]) + assert.Len(t, req.ClientName.Items, 2) + assert.Equal(t, "My Example Client", req.ClientName.Items[language.Und]) + assert.Equal(t, "\u30AF\u30E9\u30A4\u30A2\u30F3\u30C8\u540D", req.ClientName.Items[wantJPTag]) assert.Equal(t, AuthMethodBasic, req.TokenEndpointAuthMethod) - assert.Len(t, req.LogoURI, 1) - assert.Equal(t, "https://client.example.org/logo.png", req.LogoURI["default"]) + assert.Len(t, req.LogoURI.Items, 1) + assert.Equal(t, "https://client.example.org/logo.png", req.LogoURI.Items[language.Und]) assert.Equal(t, "https://client.example.org/my_public_keys.jwks", req.JWKSURI) assert.Contains(t, req.ExtraParameters, "example_extension_parameter") assert.Len(t, req.ExtraParameters, 1) @@ -139,12 +143,12 @@ func TestClientRegistrationRequest(t *testing.T) { assert.Len(t, req.RedirectURIs, 2) assert.Contains(t, req.RedirectURIs, "https://client.example.org/callback") assert.Contains(t, req.RedirectURIs, "https://client.example.org/callback2") - assert.Len(t, req.ClientName, 2) - assert.Equal(t, "My Example Client", req.ClientName["default"]) - assert.Equal(t, "\u30AF\u30E9\u30A4\u30A2\u30F3\u30C8\u540D", req.ClientName["ja-Jpan-JP"]) + assert.Len(t, req.ClientName.Items, 2) + assert.Equal(t, "My Example Client", req.ClientName.Items[language.Und]) + assert.Equal(t, "\u30AF\u30E9\u30A4\u30A2\u30F3\u30C8\u540D", req.ClientName.Items[wantJPTag]) assert.Equal(t, AuthMethodBasic, req.TokenEndpointAuthMethod) - assert.Len(t, req.PolicyURI, 1) - assert.Equal(t, "https://client.example.org/policy.html", req.PolicyURI["default"]) + assert.Len(t, req.PolicyURI.Items, 1) + assert.Equal(t, "https://client.example.org/policy.html", req.PolicyURI.Items[language.Und]) assert.Len(t, req.JWKS.Keys, 1) compareRSAJSONWebKey( t, @@ -212,11 +216,11 @@ func TestClientRegistrationRequest(t *testing.T) { assert.Len(t, req.RedirectURIs, 2) assert.Contains(t, req.RedirectURIs, "https://client.example.org/callback") assert.Contains(t, req.RedirectURIs, "https://client.example.org/callback2") - assert.Len(t, req.ClientName, 2) - assert.Equal(t, "My Example", req.ClientName["default"]) - assert.Equal(t, "\u30AF\u30E9\u30A4\u30A2\u30F3\u30C8\u540D", req.ClientName["ja-Jpan-JP"]) - assert.Len(t, req.LogoURI, 1) - assert.Equal(t, "https://client.example.org/logo.png", req.LogoURI["default"]) + assert.Len(t, req.ClientName.Items, 2) + assert.Equal(t, "My Example", req.ClientName.Items[language.Und]) + assert.Equal(t, "\u30AF\u30E9\u30A4\u30A2\u30F3\u30C8\u540D", req.ClientName.Items[wantJPTag]) + assert.Len(t, req.LogoURI.Items, 1) + assert.Equal(t, "https://client.example.org/logo.png", req.LogoURI.Items[language.Und]) assert.Equal(t, "pairwise", req.SubjectType) assert.Equal(t, "https://other.example.net/file_of_redirect_uris.json", req.SectorIdentifierURI) assert.Equal(t, AuthMethodBasic, req.TokenEndpointAuthMethod) @@ -231,6 +235,8 @@ func TestClientRegistrationRequest(t *testing.T) { }) } func TestClientReadResponse(t *testing.T) { + wantJPTag, err := language.Parse("ja-Jpan-JP") + require.NoError(t, err) // example from https://openid.net/specs/openid-connect-registration-1_0.html#ReadResponse t.Run("marshal example", func(t *testing.T) { want := ` @@ -265,13 +271,19 @@ func TestClientReadResponse(t *testing.T) { TokenEndpointAuthMethod: AuthMethodBasic, GrantTypes: nil, ResponseTypes: nil, - ClientName: map[string]string{ - "default": "My Example", - "ja-Jpan-JP": "クライアント名", + ClientName: internationalizedfield.InternationalizedField{ + FieldName: "client_name", + Items: map[language.Tag]string{ + language.Und: "My Example", + wantJPTag: "クライアント名", + }, }, - ClientURI: nil, - LogoURI: map[string]string{ - "default": "https://client.example.org/logo.png", + //ClientURI: internationalizedfield.InternationalizedField{}, + LogoURI: internationalizedfield.InternationalizedField{ + FieldName: "logo_uri", + Items: map[language.Tag]string{ + language.Und: "https://client.example.org/logo.png", + }, }, //Scope: "", Contacts: []string{"ve7jtb@example.org", "mary@example.org"}, @@ -374,6 +386,8 @@ func TestClientInformationErrorResponse(t *testing.T) { } func TestClientRegistrationResponse(t *testing.T) { + wantJPTag, err := language.Parse("ja-Jpan-JP") + require.NoError(t, err) // from https://openid.net/specs/openid-connect-registration-1_0.html#RegistrationResponse t.Run("marshal example", func(t *testing.T) { want := ` @@ -408,13 +422,19 @@ func TestClientRegistrationResponse(t *testing.T) { TokenEndpointAuthMethod: AuthMethodBasic, //GrantTypes: nil, //ResponseTypes: nil, - ClientName: map[string]string{ - "default": "My Example", - "ja-Jpan-JP": "\u30AF\u30E9\u30A4\u30A2\u30F3\u30C8\u540D", + ClientName: internationalizedfield.InternationalizedField{ + FieldName: "client_name", + Items: map[language.Tag]string{ + language.Und: "My Example", + wantJPTag: "クライアント名", + }, }, //ClientURI: nil, - LogoURI: map[string]string{ - "default": "https://client.example.org/logo.png", + LogoURI: internationalizedfield.InternationalizedField{ + FieldName: "logo_uri", + Items: map[language.Tag]string{ + language.Und: "https://client.example.org/logo.png", + }, }, //Scope: "", Contacts: []string{ @@ -497,13 +517,19 @@ func TestClientRegistrationResponse(t *testing.T) { GrantTypeRefreshToken, }, //ResponseTypes: nil, - ClientName: map[string]string{ - "default": "My Example Client", - "ja-Jpan-JP": "\u30AF\u30E9\u30A4\u30A2\u30F3\u30C8\u540D", + ClientName: internationalizedfield.InternationalizedField{ + FieldName: "client_name", + Items: map[language.Tag]string{ + language.Und: "My Example Client", + wantJPTag: "クライアント名", + }, }, //ClientURI: nil, - LogoURI: map[string]string{ - "default": "https://client.example.org/logo.png", + LogoURI: internationalizedfield.InternationalizedField{ + FieldName: "logo_uri", + Items: map[language.Tag]string{ + language.Und: "https://client.example.org/logo.png", + }, }, //Scope: "", //Contacts: nil, @@ -585,13 +611,19 @@ func TestClientRegistrationResponse(t *testing.T) { GrantTypeRefreshToken, }, ResponseTypes: nil, - ClientName: map[string]string{ - "default": "My Example Client", - "ja-Jpan-JP": "\u30AF\u30E9\u30A4\u30A2\u30F3\u30C8\u540D", + ClientName: internationalizedfield.InternationalizedField{ + FieldName: "client_name", + Items: map[language.Tag]string{ + language.Und: "My Example Client", + wantJPTag: "クライアント名", + }, }, //ClientURI: nil, - LogoURI: map[string]string{ - "default": "https://client.example.org/logo.png", + LogoURI: internationalizedfield.InternationalizedField{ + FieldName: "logo_uri", + Items: map[language.Tag]string{ + language.Und: "https://client.example.org/logo.png", + }, }, //Scope: "", //Contacts: nil, @@ -669,11 +701,11 @@ func TestClientUpdateRequest(t *testing.T) { assert.Contains(t, req.GrantTypes, GrantTypeCode) assert.Contains(t, req.GrantTypes, GrantTypeRefreshToken) assert.Equal(t, "https://client.example.org/my_public_keys.jwks", req.JWKSURI) - assert.Len(t, req.ClientName, 2) - assert.Equal(t, "My New Example", req.ClientName["default"]) - assert.Equal(t, "Mon Nouvel Exemple", req.ClientName["fr"]) - assert.Len(t, req.LogoURI, 2) - assert.Equal(t, "https://client.example.org/newlogo.png", req.LogoURI["default"]) - assert.Equal(t, "https://client.example.org/fr/newlogo.png", req.LogoURI["fr"]) + assert.Len(t, req.ClientName.Items, 2) + assert.Equal(t, "My New Example", req.ClientName.Items[language.Und]) + assert.Equal(t, "Mon Nouvel Exemple", req.ClientName.Items[language.French]) + assert.Len(t, req.LogoURI.Items, 2) + assert.Equal(t, "https://client.example.org/newlogo.png", req.LogoURI.Items[language.Und]) + assert.Equal(t, "https://client.example.org/fr/newlogo.png", req.LogoURI.Items[language.French]) }) } From eae4492a59306b3fed5a15423aa2148b09b007ab Mon Sep 17 00:00:00 2001 From: mqf20 Date: Sun, 10 Aug 2025 15:56:13 +0800 Subject: [PATCH 39/44] WIP removed marshal/unmarshal Signed-off-by: mqf20 --- .../internationalized_field.go | 50 ----------- .../internationalized_field_test.go | 83 ------------------- 2 files changed, 133 deletions(-) delete mode 100644 pkg/internationalizedfield/internationalized_field_test.go diff --git a/pkg/internationalizedfield/internationalized_field.go b/pkg/internationalizedfield/internationalized_field.go index 8ec65c1c..dd1c0c74 100644 --- a/pkg/internationalizedfield/internationalized_field.go +++ b/pkg/internationalizedfield/internationalized_field.go @@ -1,10 +1,7 @@ package internationalizedfield import ( - "encoding/json" - "fmt" "golang.org/x/text/language" - "strings" ) type languageMap = map[language.Tag]string @@ -38,50 +35,3 @@ func New(fieldName string) InternationalizedField { Items: make(languageMap), } } - -func (i *InternationalizedField) UnmarshalJSON(data []byte) error { - i.Items = make(languageMap) - - var raw map[string]interface{} - if err := json.Unmarshal(data, &raw); err != nil { - return fmt.Errorf("failed to unmarshal raw data: %w", err) - } - - for key, value := range raw { - switch { - case key == i.FieldName: - if name, ok := value.(string); ok { - // This is the default, non-tagged name. - i.Items[language.Und] = name - } - case strings.HasPrefix(key, i.FieldName+"#"): - if name, ok := value.(string); ok { - // This is a tagged name, e.g., "client_name#ja-Jpan-JP" - // Split the key at the first '#' to get the language tag. - parts := strings.SplitN(key, "#", 2) - if len(parts) == 2 { - langTag := parts[1] - if t, err := language.Parse(langTag); err != nil { - return fmt.Errorf("failed to parse language tag %q: %w", langTag, err) - } else { - i.Items[t] = name - } - } - } - } - } - return nil -} -func (i InternationalizedField) MarshalJSON() ([]byte, error) { - res := make(map[string]interface{}) - if len(i.Items) > 0 { - for lang, name := range i.Items { - if lang == language.Und { - res[i.FieldName] = name - } else { - res[fmt.Sprintf("%s#%s", i.FieldName, lang)] = name - } - } - } - return json.Marshal(res) -} diff --git a/pkg/internationalizedfield/internationalized_field_test.go b/pkg/internationalizedfield/internationalized_field_test.go deleted file mode 100644 index a1df1d3f..00000000 --- a/pkg/internationalizedfield/internationalized_field_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package internationalizedfield - -import ( - "encoding/json" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "golang.org/x/text/language" - "testing" -) - -func TestInternationalizedField_UnmarshalJSON(t *testing.T) { - wantJPTag, err := language.Parse("ja-Jpan-JP") - require.NoError(t, err) - t.Run("unmarshal valid JSON", func(t *testing.T) { - marshalled1 := []byte(` -{ - "client_name": "My Example", - "client_name#ja-Jpan-JP": "クライアント名" -} -`) - req1 := New("client_name") - require.NoError(t, json.Unmarshal(marshalled1, &req1)) - - require.Len(t, req1.Items, 2) - assert.Contains(t, req1.Items, language.Und) - assert.Contains(t, req1.Items, wantJPTag) - - assert.Equal(t, "My Example", req1.Items[language.Und]) - assert.Equal(t, "クライアント名", req1.Items[wantJPTag]) - }) - t.Run("unmarshal valid JSON with missing field", func(t *testing.T) { - marshalled1 := []byte(` -{ - "hello": "world" -} -`) - req1 := New("client_name") - require.NoError(t, json.Unmarshal(marshalled1, &req1)) - - require.Empty(t, req1.Items) - }) - t.Run("unmarshal JSON with invalid tag", func(t *testing.T) { - require.NoError(t, err) - - marshalled1 := []byte(` -{ - "client_name": "My Example", - "client_name#invalid_tag": "hello world", -} -`) - req1 := New("client_name") - require.Error(t, json.Unmarshal(marshalled1, &req1)) - }) -} - -func TestInternationalizedField_MarshalJSON(t *testing.T) { - wantJPTag, err := language.Parse("ja-Jpan-JP") - require.NoError(t, err) - t.Run("marshal valid JSON", func(t *testing.T) { - want := []byte(` -{ - "client_name": "My Example", - "client_name#ja-Jpan-JP": "クライアント名" -} -`) - req := New("client_name") - req.Items[language.Und] = "My Example" - req.Items[wantJPTag] = "クライアント名" - - marshalled, err := json.Marshal(req) - require.NoError(t, err) - - assert.JSONEq(t, string(marshalled), string(want)) - }) - t.Run("marshal empty JSON", func(t *testing.T) { - req := New("client_name") - - marshalled, err := json.Marshal(req) - require.NoError(t, err) - - assert.JSONEq(t, string(marshalled), `{}`) - }) -} From 3c979c67cc3e0e3e4d31a52170e6a75eb52d3f70 Mon Sep 17 00:00:00 2001 From: mqf20 Date: Sun, 10 Aug 2025 16:29:07 +0800 Subject: [PATCH 40/44] WIP renamed, added fields Signed-off-by: mqf20 --- example/server/storage/storage.go | 6 +- .../internationalized_field.go | 43 ++++- pkg/oidc/dynamic_client_registration.go | 169 ++---------------- pkg/oidc/dynamic_client_registration_test.go | 58 +++--- 4 files changed, 88 insertions(+), 188 deletions(-) diff --git a/example/server/storage/storage.go b/example/server/storage/storage.go index fd1edc21..3fd7b86d 100644 --- a/example/server/storage/storage.go +++ b/example/server/storage/storage.go @@ -966,7 +966,7 @@ func (s *Storage) RegisterClient(_ context.Context, c *oidc.ClientRegistrationRe ResponseTypes: client.responseTypes, ClientName: internationalizedfield.InternationalizedField{ FieldName: "client_name", - Items: map[language.Tag]string{ + Entries: map[language.Tag]string{ language.Und: client.id, }, }, @@ -1028,7 +1028,7 @@ func (s *Storage) ReadClient(_ context.Context, clientID string) (*oidc.ClientRe ResponseTypes: client.responseTypes, ClientName: internationalizedfield.InternationalizedField{ FieldName: "client_name", - Items: map[language.Tag]string{ + Entries: map[language.Tag]string{ language.Und: client.id, }, }, @@ -1095,7 +1095,7 @@ func (s *Storage) UpdateClient(_ context.Context, c *oidc.ClientUpdateRequest) ( ResponseTypes: client.responseTypes, ClientName: internationalizedfield.InternationalizedField{ FieldName: "client_name", - Items: map[language.Tag]string{ + Entries: map[language.Tag]string{ language.Und: client.id, }, }, diff --git a/pkg/internationalizedfield/internationalized_field.go b/pkg/internationalizedfield/internationalized_field.go index dd1c0c74..5044600b 100644 --- a/pkg/internationalizedfield/internationalized_field.go +++ b/pkg/internationalizedfield/internationalized_field.go @@ -1,7 +1,10 @@ package internationalizedfield import ( + "encoding/json" + "fmt" "golang.org/x/text/language" + "strings" ) type languageMap = map[language.Tag]string @@ -26,12 +29,48 @@ type languageMap = map[language.Tag]string // [Human-Readable Client Metadata]: https://www.rfc-editor.org/rfc/rfc7591#section-2.2 type InternationalizedField struct { FieldName string - Items languageMap + Entries languageMap } func New(fieldName string) InternationalizedField { return InternationalizedField{ FieldName: fieldName, - Items: make(languageMap), + Entries: make(languageMap), + } +} + +func (i InternationalizedField) InsertEntry(key string, value []byte) error { + var valStr string + if err := json.Unmarshal(value, &valStr); err != nil { + return fmt.Errorf("invalid value type for %s, expected string: %e", i.FieldName, err) + } + if key == i.FieldName { + i.Entries[language.Und] = valStr + return nil + } + if !strings.HasPrefix(key, i.FieldName+"#") { + return fmt.Errorf("invalid format for %s: %q", i.FieldName, key) + } + // This is a tagged name, e.g., "client_name#ja-Jpan-JP" + // Split the key at the first '#' to get the language tag. + parts := strings.SplitN(key, "#", 2) + if len(parts) != 2 { + return fmt.Errorf("invalid format for %s: %q", i.FieldName, key) + } + langTag, err := language.Parse(parts[1]) + if err != nil { + return fmt.Errorf("failed to parse language tag for %s: %w", i.FieldName, err) + } + i.Entries[langTag] = valStr + return nil +} + +func (i InternationalizedField) ExportEntries(res map[string]interface{}) { + for lang, name := range i.Entries { + if lang == language.Und { + res[i.FieldName] = name + } else { + res[fmt.Sprintf("%s#%s", i.FieldName, lang)] = name + } } } diff --git a/pkg/oidc/dynamic_client_registration.go b/pkg/oidc/dynamic_client_registration.go index ce46febb..baf720c8 100644 --- a/pkg/oidc/dynamic_client_registration.go +++ b/pkg/oidc/dynamic_client_registration.go @@ -6,7 +6,6 @@ import ( "fmt" "github.com/go-jose/go-jose/v4" "github.com/zitadel/oidc/v3/pkg/internationalizedfield" - "golang.org/x/text/language" "strings" ) @@ -532,75 +531,18 @@ func (c *ClientMetadata) UnmarshalJSON(data []byte) error { // should we check against ResponseTypeMap if response_types is valid? return err } - case key == "client_name": - var name string - if err := json.Unmarshal(value, &name); err != nil { + case strings.HasPrefix(key, c.ClientName.FieldName): + if err := c.ClientName.InsertEntry(key, value); err != nil { return err } - c.ClientName.Items[language.Und] = name - case strings.HasPrefix(key, "client_name#"): - var name string - if err := json.Unmarshal(value, &name); err != nil { + case strings.HasPrefix(key, c.ClientURI.FieldName): + if err := c.ClientURI.InsertEntry(key, value); err != nil { return err } - // This is a tagged name, e.g., "client_name#ja-Jpan-JP" - // Split the key at the first '#' to get the language tag. - parts := strings.SplitN(key, "#", 2) - if len(parts) == 2 { - langTag, err := language.Parse(parts[1]) - if err != nil { - return fmt.Errorf("failed to parse language tag for client_name: %w", err) - } - c.ClientName.Items[langTag] = name - } else { - return fmt.Errorf("invalid client_name format: %q", key) - } - case key == "client_uri": - var uri string - if err := json.Unmarshal(value, &uri); err != nil { - return err - } - c.ClientURI.Items[language.Und] = uri - case strings.HasPrefix(key, "client_uri#"): - var uri string - if err := json.Unmarshal(value, &uri); err != nil { + case strings.HasPrefix(key, c.LogoURI.FieldName): + if err := c.LogoURI.InsertEntry(key, value); err != nil { return err } - // This is a tagged name, e.g., "client_uri#ja-Jpan-JP" - // Split the key at the first '#' to get the language tag. - parts := strings.SplitN(key, "#", 2) - if len(parts) == 2 { - langTag, err := language.Parse(parts[1]) - if err != nil { - return fmt.Errorf("failed to parse language tag for client_uri: %w", err) - } - c.ClientURI.Items[langTag] = uri - } else { - return fmt.Errorf("invalid client_uri format: %q", key) - } - case key == "logo_uri": - var uri string - if err := json.Unmarshal(value, &uri); err != nil { - return err - } - c.LogoURI.Items[language.Und] = uri - case strings.HasPrefix(key, "logo_uri#"): - var uri string - if err := json.Unmarshal(value, &uri); err != nil { - return err - } - // This is a tagged name, e.g., "logo_uri#ja-Jpan-JP" - // Split the key at the first '#' to get the language tag. - parts := strings.SplitN(key, "#", 2) - if len(parts) == 2 { - langTag, err := language.Parse(parts[1]) - if err != nil { - return fmt.Errorf("failed to parse language tag for logo_uri: %w", err) - } - c.LogoURI.Items[langTag] = uri - } else { - return fmt.Errorf("invalid logo_uri format: %q", key) - } case key == "scope": if err := json.Unmarshal(value, &c.Scope); err != nil { return err @@ -609,52 +551,14 @@ func (c *ClientMetadata) UnmarshalJSON(data []byte) error { if err := json.Unmarshal(value, &c.Contacts); err != nil { return err } - case key == "tos_uri": - var uri string - if err := json.Unmarshal(value, &uri); err != nil { + case strings.HasPrefix(key, c.TOSURI.FieldName): + if err := c.TOSURI.InsertEntry(key, value); err != nil { return err } - c.TOSURI.Items[language.Und] = uri - case strings.HasPrefix(key, "tos_uri#"): - var uri string - if err := json.Unmarshal(value, &uri); err != nil { + case strings.HasPrefix(key, c.PolicyURI.FieldName): + if err := c.PolicyURI.InsertEntry(key, value); err != nil { return err } - // This is a tagged name, e.g., "tos_uri#ja-Jpan-JP" - // Split the key at the first '#' to get the language tag. - parts := strings.SplitN(key, "#", 2) - if len(parts) == 2 { - langTag, err := language.Parse(parts[1]) - if err != nil { - return fmt.Errorf("failed to parse language tag for tos_uri: %w", err) - } - c.TOSURI.Items[langTag] = uri - } else { - return fmt.Errorf("invalid client_uri format: %q", key) - } - case key == "policy_uri": - var uri string - if err := json.Unmarshal(value, &uri); err != nil { - return err - } - c.PolicyURI.Items[language.Und] = uri - case strings.HasPrefix(key, "policy_uri#"): - var uri string - if err := json.Unmarshal(value, &uri); err != nil { - return err - } - // This is a tagged name, e.g., "policy_uri#ja-Jpan-JP" - // Split the key at the first '#' to get the language tag. - parts := strings.SplitN(key, "#", 2) - if len(parts) == 2 { - langTag, err := language.Parse(parts[1]) - if err != nil { - return fmt.Errorf("failed to parse language tag for policy_uri: %w", err) - } - c.PolicyURI.Items[langTag] = uri - } else { - return fmt.Errorf("invalid client_uri format: %q", key) - } case key == "jwks_uri": if err := json.Unmarshal(value, &c.JWKSURI); err != nil { return err @@ -810,35 +714,9 @@ func (c ClientMetadata) MarshalJSON() ([]byte, error) { res["response_types"] = c.ResponseTypes } - if len(c.ClientName.Items) > 0 { - for lang, name := range c.ClientName.Items { - if lang == language.Und { - res[c.ClientName.FieldName] = name - } else { - res[fmt.Sprintf("%s#%s", c.ClientName.FieldName, lang)] = name - } - } - } - - if len(c.ClientURI.Items) > 0 { - for lang, uri := range c.ClientURI.Items { - if lang == language.Und { - res[c.ClientURI.FieldName] = uri - } else { - res[fmt.Sprintf("%s#%s", c.ClientURI.FieldName, lang)] = uri - } - } - } - - if len(c.LogoURI.Items) > 0 { - for lang, logo := range c.LogoURI.Items { - if lang == language.Und { - res[c.LogoURI.FieldName] = logo - } else { - res[fmt.Sprintf("%s#%s", c.LogoURI.FieldName, lang)] = logo - } - } - } + c.ClientName.ExportEntries(res) + c.ClientURI.ExportEntries(res) + c.LogoURI.ExportEntries(res) if c.Scope != "" { res["scope"] = c.Scope @@ -848,25 +726,8 @@ func (c ClientMetadata) MarshalJSON() ([]byte, error) { res["contacts"] = c.Contacts } - if len(c.TOSURI.Items) > 0 { - for lang, uri := range c.TOSURI.Items { - if lang == language.Und { - res[c.TOSURI.FieldName] = uri - } else { - res[fmt.Sprintf("%s#%s", c.TOSURI.FieldName, lang)] = uri - } - } - } - - if len(c.PolicyURI.Items) > 0 { - for lang, uri := range c.PolicyURI.Items { - if lang == language.Und { - res[c.PolicyURI.FieldName] = uri - } else { - res[fmt.Sprintf("%s#%s", c.PolicyURI.FieldName, lang)] = uri - } - } - } + c.TOSURI.ExportEntries(res) + c.PolicyURI.ExportEntries(res) if c.JWKSURI != "" { res["jwks_uri"] = c.JWKSURI diff --git a/pkg/oidc/dynamic_client_registration_test.go b/pkg/oidc/dynamic_client_registration_test.go index 9efd65ce..575c6d4a 100644 --- a/pkg/oidc/dynamic_client_registration_test.go +++ b/pkg/oidc/dynamic_client_registration_test.go @@ -103,12 +103,12 @@ func TestClientRegistrationRequest(t *testing.T) { assert.Len(t, req.RedirectURIs, 2) assert.Contains(t, req.RedirectURIs, "https://client.example.org/callback") assert.Contains(t, req.RedirectURIs, "https://client.example.org/callback2") - assert.Len(t, req.ClientName.Items, 2) - assert.Equal(t, "My Example Client", req.ClientName.Items[language.Und]) - assert.Equal(t, "\u30AF\u30E9\u30A4\u30A2\u30F3\u30C8\u540D", req.ClientName.Items[wantJPTag]) + assert.Len(t, req.ClientName.Entries, 2) + assert.Equal(t, "My Example Client", req.ClientName.Entries[language.Und]) + assert.Equal(t, "\u30AF\u30E9\u30A4\u30A2\u30F3\u30C8\u540D", req.ClientName.Entries[wantJPTag]) assert.Equal(t, AuthMethodBasic, req.TokenEndpointAuthMethod) - assert.Len(t, req.LogoURI.Items, 1) - assert.Equal(t, "https://client.example.org/logo.png", req.LogoURI.Items[language.Und]) + assert.Len(t, req.LogoURI.Entries, 1) + assert.Equal(t, "https://client.example.org/logo.png", req.LogoURI.Entries[language.Und]) assert.Equal(t, "https://client.example.org/my_public_keys.jwks", req.JWKSURI) assert.Contains(t, req.ExtraParameters, "example_extension_parameter") assert.Len(t, req.ExtraParameters, 1) @@ -143,12 +143,12 @@ func TestClientRegistrationRequest(t *testing.T) { assert.Len(t, req.RedirectURIs, 2) assert.Contains(t, req.RedirectURIs, "https://client.example.org/callback") assert.Contains(t, req.RedirectURIs, "https://client.example.org/callback2") - assert.Len(t, req.ClientName.Items, 2) - assert.Equal(t, "My Example Client", req.ClientName.Items[language.Und]) - assert.Equal(t, "\u30AF\u30E9\u30A4\u30A2\u30F3\u30C8\u540D", req.ClientName.Items[wantJPTag]) + assert.Len(t, req.ClientName.Entries, 2) + assert.Equal(t, "My Example Client", req.ClientName.Entries[language.Und]) + assert.Equal(t, "\u30AF\u30E9\u30A4\u30A2\u30F3\u30C8\u540D", req.ClientName.Entries[wantJPTag]) assert.Equal(t, AuthMethodBasic, req.TokenEndpointAuthMethod) - assert.Len(t, req.PolicyURI.Items, 1) - assert.Equal(t, "https://client.example.org/policy.html", req.PolicyURI.Items[language.Und]) + assert.Len(t, req.PolicyURI.Entries, 1) + assert.Equal(t, "https://client.example.org/policy.html", req.PolicyURI.Entries[language.Und]) assert.Len(t, req.JWKS.Keys, 1) compareRSAJSONWebKey( t, @@ -216,11 +216,11 @@ func TestClientRegistrationRequest(t *testing.T) { assert.Len(t, req.RedirectURIs, 2) assert.Contains(t, req.RedirectURIs, "https://client.example.org/callback") assert.Contains(t, req.RedirectURIs, "https://client.example.org/callback2") - assert.Len(t, req.ClientName.Items, 2) - assert.Equal(t, "My Example", req.ClientName.Items[language.Und]) - assert.Equal(t, "\u30AF\u30E9\u30A4\u30A2\u30F3\u30C8\u540D", req.ClientName.Items[wantJPTag]) - assert.Len(t, req.LogoURI.Items, 1) - assert.Equal(t, "https://client.example.org/logo.png", req.LogoURI.Items[language.Und]) + assert.Len(t, req.ClientName.Entries, 2) + assert.Equal(t, "My Example", req.ClientName.Entries[language.Und]) + assert.Equal(t, "\u30AF\u30E9\u30A4\u30A2\u30F3\u30C8\u540D", req.ClientName.Entries[wantJPTag]) + assert.Len(t, req.LogoURI.Entries, 1) + assert.Equal(t, "https://client.example.org/logo.png", req.LogoURI.Entries[language.Und]) assert.Equal(t, "pairwise", req.SubjectType) assert.Equal(t, "https://other.example.net/file_of_redirect_uris.json", req.SectorIdentifierURI) assert.Equal(t, AuthMethodBasic, req.TokenEndpointAuthMethod) @@ -273,7 +273,7 @@ func TestClientReadResponse(t *testing.T) { ResponseTypes: nil, ClientName: internationalizedfield.InternationalizedField{ FieldName: "client_name", - Items: map[language.Tag]string{ + Entries: map[language.Tag]string{ language.Und: "My Example", wantJPTag: "クライアント名", }, @@ -281,7 +281,7 @@ func TestClientReadResponse(t *testing.T) { //ClientURI: internationalizedfield.InternationalizedField{}, LogoURI: internationalizedfield.InternationalizedField{ FieldName: "logo_uri", - Items: map[language.Tag]string{ + Entries: map[language.Tag]string{ language.Und: "https://client.example.org/logo.png", }, }, @@ -424,7 +424,7 @@ func TestClientRegistrationResponse(t *testing.T) { //ResponseTypes: nil, ClientName: internationalizedfield.InternationalizedField{ FieldName: "client_name", - Items: map[language.Tag]string{ + Entries: map[language.Tag]string{ language.Und: "My Example", wantJPTag: "クライアント名", }, @@ -432,7 +432,7 @@ func TestClientRegistrationResponse(t *testing.T) { //ClientURI: nil, LogoURI: internationalizedfield.InternationalizedField{ FieldName: "logo_uri", - Items: map[language.Tag]string{ + Entries: map[language.Tag]string{ language.Und: "https://client.example.org/logo.png", }, }, @@ -519,7 +519,7 @@ func TestClientRegistrationResponse(t *testing.T) { //ResponseTypes: nil, ClientName: internationalizedfield.InternationalizedField{ FieldName: "client_name", - Items: map[language.Tag]string{ + Entries: map[language.Tag]string{ language.Und: "My Example Client", wantJPTag: "クライアント名", }, @@ -527,7 +527,7 @@ func TestClientRegistrationResponse(t *testing.T) { //ClientURI: nil, LogoURI: internationalizedfield.InternationalizedField{ FieldName: "logo_uri", - Items: map[language.Tag]string{ + Entries: map[language.Tag]string{ language.Und: "https://client.example.org/logo.png", }, }, @@ -613,7 +613,7 @@ func TestClientRegistrationResponse(t *testing.T) { ResponseTypes: nil, ClientName: internationalizedfield.InternationalizedField{ FieldName: "client_name", - Items: map[language.Tag]string{ + Entries: map[language.Tag]string{ language.Und: "My Example Client", wantJPTag: "クライアント名", }, @@ -621,7 +621,7 @@ func TestClientRegistrationResponse(t *testing.T) { //ClientURI: nil, LogoURI: internationalizedfield.InternationalizedField{ FieldName: "logo_uri", - Items: map[language.Tag]string{ + Entries: map[language.Tag]string{ language.Und: "https://client.example.org/logo.png", }, }, @@ -701,11 +701,11 @@ func TestClientUpdateRequest(t *testing.T) { assert.Contains(t, req.GrantTypes, GrantTypeCode) assert.Contains(t, req.GrantTypes, GrantTypeRefreshToken) assert.Equal(t, "https://client.example.org/my_public_keys.jwks", req.JWKSURI) - assert.Len(t, req.ClientName.Items, 2) - assert.Equal(t, "My New Example", req.ClientName.Items[language.Und]) - assert.Equal(t, "Mon Nouvel Exemple", req.ClientName.Items[language.French]) - assert.Len(t, req.LogoURI.Items, 2) - assert.Equal(t, "https://client.example.org/newlogo.png", req.LogoURI.Items[language.Und]) - assert.Equal(t, "https://client.example.org/fr/newlogo.png", req.LogoURI.Items[language.French]) + assert.Len(t, req.ClientName.Entries, 2) + assert.Equal(t, "My New Example", req.ClientName.Entries[language.Und]) + assert.Equal(t, "Mon Nouvel Exemple", req.ClientName.Entries[language.French]) + assert.Len(t, req.LogoURI.Entries, 2) + assert.Equal(t, "https://client.example.org/newlogo.png", req.LogoURI.Entries[language.Und]) + assert.Equal(t, "https://client.example.org/fr/newlogo.png", req.LogoURI.Entries[language.French]) }) } From 4fba202d8f6b7c603eef197f9a605a272ea1ddc6 Mon Sep 17 00:00:00 2001 From: mqf20 Date: Sun, 10 Aug 2025 16:38:49 +0800 Subject: [PATCH 41/44] WIP added helpers Signed-off-by: mqf20 --- pkg/oidc/dynamic_client_registration.go | 41 +++++++++-------- pkg/oidc/dynamic_client_registration_test.go | 45 +++++++++---------- .../internationalized_field.go | 23 ++++++++-- 3 files changed, 61 insertions(+), 48 deletions(-) rename pkg/{internationalizedfield => oidc}/internationalized_field.go (79%) diff --git a/pkg/oidc/dynamic_client_registration.go b/pkg/oidc/dynamic_client_registration.go index baf720c8..bd5bde68 100644 --- a/pkg/oidc/dynamic_client_registration.go +++ b/pkg/oidc/dynamic_client_registration.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "github.com/go-jose/go-jose/v4" - "github.com/zitadel/oidc/v3/pkg/internationalizedfield" "strings" ) @@ -130,7 +129,7 @@ type ClientMetadata struct { // [Section 2.2]. // // [Section 2.2]: https://www.rfc-editor.org/rfc/rfc7591#section-2.2 - ClientName internationalizedfield.InternationalizedField `json:"client_name"` + ClientName InternationalizedField `json:"client_name"` // ClientURI is a URL string of a web page providing information about the client. // If present, the server SHOULD display this URL to the end-user in @@ -140,7 +139,7 @@ type ClientMetadata struct { // described in [Section 2.2]. // // [Section 2.2]: https://www.rfc-editor.org/rfc/rfc7591#section-2.2 - ClientURI internationalizedfield.InternationalizedField `json:"client_uri"` + ClientURI InternationalizedField `json:"client_uri"` // LogoURI is a URL string that references a logo for the client. If present, the // server SHOULD display this image to the end-user during approval. @@ -149,7 +148,7 @@ type ClientMetadata struct { // [Section 2.2]. // // [Section 2.2]: https://www.rfc-editor.org/rfc/rfc7591#section-2.2 - LogoURI internationalizedfield.InternationalizedField `json:"logo_uri"` + LogoURI InternationalizedField `json:"logo_uri"` // Scope is a string containing a space-separated list of scope values (as // described in [Section 3.3] of OAuth 2.0 [RFC6749]) that the client @@ -179,7 +178,7 @@ type ClientMetadata struct { // be internationalized, as described in [Section 2.2]. // // [Section 2.2]: https://www.rfc-editor.org/rfc/rfc7591#section-2.2 - TOSURI internationalizedfield.InternationalizedField `json:"tos_uri"` + TOSURI InternationalizedField `json:"tos_uri"` // PolicyURI is a URL string that points to a human-readable privacy policy document // that describes how the deployment organization collects, uses, @@ -189,7 +188,7 @@ type ClientMetadata struct { // this field MAY be internationalized, as described in [Section 2.2]. // // [Section 2.2]: https://www.rfc-editor.org/rfc/rfc7591#section-2.2 - PolicyURI internationalizedfield.InternationalizedField `json:"policy_uri"` + PolicyURI InternationalizedField `json:"policy_uri"` // JWKSURI is a URL string referencing the client's JSON Web Key (JWK) Set // [RFC7517] document, which contains the client's public keys. The @@ -496,11 +495,11 @@ type ClientMetadata struct { func (c *ClientMetadata) UnmarshalJSON(data []byte) error { // Initialize maps to avoid nil pointer issues later. - c.ClientName = internationalizedfield.New("client_name") - c.ClientURI = internationalizedfield.New("client_uri") - c.LogoURI = internationalizedfield.New("logo_uri") - c.TOSURI = internationalizedfield.New("tos_uri") - c.PolicyURI = internationalizedfield.New("policy_uri") + c.ClientName = NewInternationalizedField("client_name") + c.ClientURI = NewInternationalizedField("client_uri") + c.LogoURI = NewInternationalizedField("logo_uri") + c.TOSURI = NewInternationalizedField("tos_uri") + c.PolicyURI = NewInternationalizedField("policy_uri") c.ExtraParameters = make(map[string]interface{}) // Unmarshal into a temporary map to inspect all keys. @@ -532,15 +531,15 @@ func (c *ClientMetadata) UnmarshalJSON(data []byte) error { return err } case strings.HasPrefix(key, c.ClientName.FieldName): - if err := c.ClientName.InsertEntry(key, value); err != nil { + if err := c.ClientName.insertEntry(key, value); err != nil { return err } case strings.HasPrefix(key, c.ClientURI.FieldName): - if err := c.ClientURI.InsertEntry(key, value); err != nil { + if err := c.ClientURI.insertEntry(key, value); err != nil { return err } case strings.HasPrefix(key, c.LogoURI.FieldName): - if err := c.LogoURI.InsertEntry(key, value); err != nil { + if err := c.LogoURI.insertEntry(key, value); err != nil { return err } case key == "scope": @@ -552,11 +551,11 @@ func (c *ClientMetadata) UnmarshalJSON(data []byte) error { return err } case strings.HasPrefix(key, c.TOSURI.FieldName): - if err := c.TOSURI.InsertEntry(key, value); err != nil { + if err := c.TOSURI.insertEntry(key, value); err != nil { return err } case strings.HasPrefix(key, c.PolicyURI.FieldName): - if err := c.PolicyURI.InsertEntry(key, value); err != nil { + if err := c.PolicyURI.insertEntry(key, value); err != nil { return err } case key == "jwks_uri": @@ -714,9 +713,9 @@ func (c ClientMetadata) MarshalJSON() ([]byte, error) { res["response_types"] = c.ResponseTypes } - c.ClientName.ExportEntries(res) - c.ClientURI.ExportEntries(res) - c.LogoURI.ExportEntries(res) + c.ClientName.exportEntries(res) + c.ClientURI.exportEntries(res) + c.LogoURI.exportEntries(res) if c.Scope != "" { res["scope"] = c.Scope @@ -726,8 +725,8 @@ func (c ClientMetadata) MarshalJSON() ([]byte, error) { res["contacts"] = c.Contacts } - c.TOSURI.ExportEntries(res) - c.PolicyURI.ExportEntries(res) + c.TOSURI.exportEntries(res) + c.PolicyURI.exportEntries(res) if c.JWKSURI != "" { res["jwks_uri"] = c.JWKSURI diff --git a/pkg/oidc/dynamic_client_registration_test.go b/pkg/oidc/dynamic_client_registration_test.go index 575c6d4a..3fa57ae0 100644 --- a/pkg/oidc/dynamic_client_registration_test.go +++ b/pkg/oidc/dynamic_client_registration_test.go @@ -7,7 +7,6 @@ import ( "github.com/go-jose/go-jose/v4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/zitadel/oidc/v3/pkg/internationalizedfield" "golang.org/x/text/language" "math/big" "testing" @@ -104,11 +103,11 @@ func TestClientRegistrationRequest(t *testing.T) { assert.Contains(t, req.RedirectURIs, "https://client.example.org/callback") assert.Contains(t, req.RedirectURIs, "https://client.example.org/callback2") assert.Len(t, req.ClientName.Entries, 2) - assert.Equal(t, "My Example Client", req.ClientName.Entries[language.Und]) - assert.Equal(t, "\u30AF\u30E9\u30A4\u30A2\u30F3\u30C8\u540D", req.ClientName.Entries[wantJPTag]) + assert.Equal(t, "My Example Client", req.ClientName.GetDefaultEntry()) + assert.Equal(t, "\u30AF\u30E9\u30A4\u30A2\u30F3\u30C8\u540D", req.ClientName.GetEntry(wantJPTag)) assert.Equal(t, AuthMethodBasic, req.TokenEndpointAuthMethod) assert.Len(t, req.LogoURI.Entries, 1) - assert.Equal(t, "https://client.example.org/logo.png", req.LogoURI.Entries[language.Und]) + assert.Equal(t, "https://client.example.org/logo.png", req.LogoURI.GetDefaultEntry()) assert.Equal(t, "https://client.example.org/my_public_keys.jwks", req.JWKSURI) assert.Contains(t, req.ExtraParameters, "example_extension_parameter") assert.Len(t, req.ExtraParameters, 1) @@ -144,11 +143,11 @@ func TestClientRegistrationRequest(t *testing.T) { assert.Contains(t, req.RedirectURIs, "https://client.example.org/callback") assert.Contains(t, req.RedirectURIs, "https://client.example.org/callback2") assert.Len(t, req.ClientName.Entries, 2) - assert.Equal(t, "My Example Client", req.ClientName.Entries[language.Und]) - assert.Equal(t, "\u30AF\u30E9\u30A4\u30A2\u30F3\u30C8\u540D", req.ClientName.Entries[wantJPTag]) + assert.Equal(t, "My Example Client", req.ClientName.GetDefaultEntry()) + assert.Equal(t, "\u30AF\u30E9\u30A4\u30A2\u30F3\u30C8\u540D", req.ClientName.GetEntry(wantJPTag)) assert.Equal(t, AuthMethodBasic, req.TokenEndpointAuthMethod) assert.Len(t, req.PolicyURI.Entries, 1) - assert.Equal(t, "https://client.example.org/policy.html", req.PolicyURI.Entries[language.Und]) + assert.Equal(t, "https://client.example.org/policy.html", req.PolicyURI.GetDefaultEntry()) assert.Len(t, req.JWKS.Keys, 1) compareRSAJSONWebKey( t, @@ -217,10 +216,10 @@ func TestClientRegistrationRequest(t *testing.T) { assert.Contains(t, req.RedirectURIs, "https://client.example.org/callback") assert.Contains(t, req.RedirectURIs, "https://client.example.org/callback2") assert.Len(t, req.ClientName.Entries, 2) - assert.Equal(t, "My Example", req.ClientName.Entries[language.Und]) - assert.Equal(t, "\u30AF\u30E9\u30A4\u30A2\u30F3\u30C8\u540D", req.ClientName.Entries[wantJPTag]) + assert.Equal(t, "My Example", req.ClientName.GetDefaultEntry()) + assert.Equal(t, "\u30AF\u30E9\u30A4\u30A2\u30F3\u30C8\u540D", req.ClientName.GetEntry(wantJPTag)) assert.Len(t, req.LogoURI.Entries, 1) - assert.Equal(t, "https://client.example.org/logo.png", req.LogoURI.Entries[language.Und]) + assert.Equal(t, "https://client.example.org/logo.png", req.LogoURI.GetDefaultEntry()) assert.Equal(t, "pairwise", req.SubjectType) assert.Equal(t, "https://other.example.net/file_of_redirect_uris.json", req.SectorIdentifierURI) assert.Equal(t, AuthMethodBasic, req.TokenEndpointAuthMethod) @@ -271,15 +270,15 @@ func TestClientReadResponse(t *testing.T) { TokenEndpointAuthMethod: AuthMethodBasic, GrantTypes: nil, ResponseTypes: nil, - ClientName: internationalizedfield.InternationalizedField{ + ClientName: InternationalizedField{ FieldName: "client_name", Entries: map[language.Tag]string{ language.Und: "My Example", wantJPTag: "クライアント名", }, }, - //ClientURI: internationalizedfield.InternationalizedField{}, - LogoURI: internationalizedfield.InternationalizedField{ + //ClientURI: InternationalizedField{}, + LogoURI: InternationalizedField{ FieldName: "logo_uri", Entries: map[language.Tag]string{ language.Und: "https://client.example.org/logo.png", @@ -422,7 +421,7 @@ func TestClientRegistrationResponse(t *testing.T) { TokenEndpointAuthMethod: AuthMethodBasic, //GrantTypes: nil, //ResponseTypes: nil, - ClientName: internationalizedfield.InternationalizedField{ + ClientName: InternationalizedField{ FieldName: "client_name", Entries: map[language.Tag]string{ language.Und: "My Example", @@ -430,7 +429,7 @@ func TestClientRegistrationResponse(t *testing.T) { }, }, //ClientURI: nil, - LogoURI: internationalizedfield.InternationalizedField{ + LogoURI: InternationalizedField{ FieldName: "logo_uri", Entries: map[language.Tag]string{ language.Und: "https://client.example.org/logo.png", @@ -517,7 +516,7 @@ func TestClientRegistrationResponse(t *testing.T) { GrantTypeRefreshToken, }, //ResponseTypes: nil, - ClientName: internationalizedfield.InternationalizedField{ + ClientName: InternationalizedField{ FieldName: "client_name", Entries: map[language.Tag]string{ language.Und: "My Example Client", @@ -525,7 +524,7 @@ func TestClientRegistrationResponse(t *testing.T) { }, }, //ClientURI: nil, - LogoURI: internationalizedfield.InternationalizedField{ + LogoURI: InternationalizedField{ FieldName: "logo_uri", Entries: map[language.Tag]string{ language.Und: "https://client.example.org/logo.png", @@ -611,7 +610,7 @@ func TestClientRegistrationResponse(t *testing.T) { GrantTypeRefreshToken, }, ResponseTypes: nil, - ClientName: internationalizedfield.InternationalizedField{ + ClientName: InternationalizedField{ FieldName: "client_name", Entries: map[language.Tag]string{ language.Und: "My Example Client", @@ -619,7 +618,7 @@ func TestClientRegistrationResponse(t *testing.T) { }, }, //ClientURI: nil, - LogoURI: internationalizedfield.InternationalizedField{ + LogoURI: InternationalizedField{ FieldName: "logo_uri", Entries: map[language.Tag]string{ language.Und: "https://client.example.org/logo.png", @@ -702,10 +701,10 @@ func TestClientUpdateRequest(t *testing.T) { assert.Contains(t, req.GrantTypes, GrantTypeRefreshToken) assert.Equal(t, "https://client.example.org/my_public_keys.jwks", req.JWKSURI) assert.Len(t, req.ClientName.Entries, 2) - assert.Equal(t, "My New Example", req.ClientName.Entries[language.Und]) - assert.Equal(t, "Mon Nouvel Exemple", req.ClientName.Entries[language.French]) + assert.Equal(t, "My New Example", req.ClientName.GetDefaultEntry()) + assert.Equal(t, "Mon Nouvel Exemple", req.ClientName.GetEntry(language.French)) assert.Len(t, req.LogoURI.Entries, 2) - assert.Equal(t, "https://client.example.org/newlogo.png", req.LogoURI.Entries[language.Und]) - assert.Equal(t, "https://client.example.org/fr/newlogo.png", req.LogoURI.Entries[language.French]) + assert.Equal(t, "https://client.example.org/newlogo.png", req.LogoURI.GetDefaultEntry()) + assert.Equal(t, "https://client.example.org/fr/newlogo.png", req.LogoURI.GetEntry(language.French)) }) } diff --git a/pkg/internationalizedfield/internationalized_field.go b/pkg/oidc/internationalized_field.go similarity index 79% rename from pkg/internationalizedfield/internationalized_field.go rename to pkg/oidc/internationalized_field.go index 5044600b..7d1b2bdc 100644 --- a/pkg/internationalizedfield/internationalized_field.go +++ b/pkg/oidc/internationalized_field.go @@ -1,4 +1,4 @@ -package internationalizedfield +package oidc import ( "encoding/json" @@ -32,14 +32,14 @@ type InternationalizedField struct { Entries languageMap } -func New(fieldName string) InternationalizedField { +func NewInternationalizedField(fieldName string) InternationalizedField { return InternationalizedField{ FieldName: fieldName, Entries: make(languageMap), } } -func (i InternationalizedField) InsertEntry(key string, value []byte) error { +func (i InternationalizedField) insertEntry(key string, value []byte) error { var valStr string if err := json.Unmarshal(value, &valStr); err != nil { return fmt.Errorf("invalid value type for %s, expected string: %e", i.FieldName, err) @@ -65,7 +65,7 @@ func (i InternationalizedField) InsertEntry(key string, value []byte) error { return nil } -func (i InternationalizedField) ExportEntries(res map[string]interface{}) { +func (i InternationalizedField) exportEntries(res map[string]interface{}) { for lang, name := range i.Entries { if lang == language.Und { res[i.FieldName] = name @@ -74,3 +74,18 @@ func (i InternationalizedField) ExportEntries(res map[string]interface{}) { } } } + +func (i InternationalizedField) GetDefaultEntry() string { + val := i.GetEntry(language.Und) + if val == "" { + for _, v := range i.Entries { + // return any entry + return v + } + } + return val +} + +func (i InternationalizedField) GetEntry(lang language.Tag) string { + return i.Entries[lang] +} From 6899c5868d75f0244a2937214d5015ef27328d81 Mon Sep 17 00:00:00 2001 From: mqf20 Date: Sun, 10 Aug 2025 16:40:15 +0800 Subject: [PATCH 42/44] WIP fixed example Signed-off-by: mqf20 --- example/server/storage/storage.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/example/server/storage/storage.go b/example/server/storage/storage.go index 3fd7b86d..290f6b9b 100644 --- a/example/server/storage/storage.go +++ b/example/server/storage/storage.go @@ -6,7 +6,6 @@ import ( "crypto/rsa" "errors" "fmt" - "github.com/zitadel/oidc/v3/pkg/internationalizedfield" "golang.org/x/text/language" "math/big" "strings" @@ -964,7 +963,7 @@ func (s *Storage) RegisterClient(_ context.Context, c *oidc.ClientRegistrationRe TokenEndpointAuthMethod: client.authMethod, GrantTypes: client.grantTypes, ResponseTypes: client.responseTypes, - ClientName: internationalizedfield.InternationalizedField{ + ClientName: oidc.InternationalizedField{ FieldName: "client_name", Entries: map[language.Tag]string{ language.Und: client.id, @@ -1026,7 +1025,7 @@ func (s *Storage) ReadClient(_ context.Context, clientID string) (*oidc.ClientRe TokenEndpointAuthMethod: client.authMethod, GrantTypes: client.grantTypes, ResponseTypes: client.responseTypes, - ClientName: internationalizedfield.InternationalizedField{ + ClientName: oidc.InternationalizedField{ FieldName: "client_name", Entries: map[language.Tag]string{ language.Und: client.id, @@ -1093,7 +1092,7 @@ func (s *Storage) UpdateClient(_ context.Context, c *oidc.ClientUpdateRequest) ( TokenEndpointAuthMethod: client.authMethod, GrantTypes: client.grantTypes, ResponseTypes: client.responseTypes, - ClientName: internationalizedfield.InternationalizedField{ + ClientName: oidc.InternationalizedField{ FieldName: "client_name", Entries: map[language.Tag]string{ language.Und: client.id, From f94ee4642ac922145713e8ba3e7418173cf9aa94 Mon Sep 17 00:00:00 2001 From: mqf20 Date: Sun, 10 Aug 2025 17:18:48 +0800 Subject: [PATCH 43/44] WIP fixed handlers Signed-off-by: mqf20 --- pkg/op/dynamic_client_registration.go | 29 +++++++++++++-------------- pkg/op/op.go | 4 ++-- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/pkg/op/dynamic_client_registration.go b/pkg/op/dynamic_client_registration.go index d8b92501..8a0c98d6 100644 --- a/pkg/op/dynamic_client_registration.go +++ b/pkg/op/dynamic_client_registration.go @@ -43,11 +43,15 @@ func clientRequestError(w http.ResponseWriter, r *http.Request, lvl slog.Level, httphelper.MarshalJSONWithStatus(w, errResp, status) } -func clientReadHandler(o OpenIDProvider) func(http.ResponseWriter, *http.Request) { +func clientReadUpdateDeleteHandler(o OpenIDProvider) func(http.ResponseWriter, *http.Request) { return func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: clientRead(w, r, o) + case http.MethodPut: + clientUpdate(w, r, o) + case http.MethodDelete: + clientDelete(w, r, o) default: RequestError(w, r, fmt.Errorf("unsupported method: %s", r.Method), o.Logger()) } @@ -124,15 +128,11 @@ func ParseClientReadRequest(r *http.Request, o OpenIDProvider) (*oidc.ClientRead return req, nil } -func clientRegistrationUpdateDeleteHandler(o OpenIDProvider) func(http.ResponseWriter, *http.Request) { +func clientRegistrationHandler(o OpenIDProvider) func(http.ResponseWriter, *http.Request) { return func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodPost: clientRegistration(w, r, o) - case http.MethodPut: - clientUpdate(w, r, o) - case http.MethodDelete: - clientDelete(w, r, o) default: RequestError(w, r, fmt.Errorf("unsupported method: %s", r.Method), o.Logger()) } @@ -162,7 +162,7 @@ func clientRegistration(w http.ResponseWriter, r *http.Request, o OpenIDProvider } var initialAccessToken string - if auth := r.Header.Get("authorization"); auth == "" { + if auth := r.Header.Get("authorization"); auth != "" { iat, err := getBearerToken(r) if err != nil && !errors.Is(err, errMissingAuthorizationHeader) { // allow for missing authorization header, in case the software statement is used for authentication @@ -355,16 +355,18 @@ func clientUpdate(w http.ResponseWriter, r *http.Request, o OpenIDProvider) { return } -func ParseClientUpdateRequest(r *http.Request, o OpenIDProvider) (*oidc.ClientUpdateRequest, error) { +func ParseClientUpdateRequest(r *http.Request, _ OpenIDProvider) (*oidc.ClientUpdateRequest, error) { ctx, span := tracer.Start(r.Context(), "ParseClientUpdateRequest") r = r.WithContext(ctx) defer span.End() req := new(oidc.ClientUpdateRequest) - if err := o.Decoder().Decode(req, r.Form); err != nil { + if err := json.NewDecoder(r.Body).Decode(req); err != nil { return nil, oidc.ErrInvalidRequest().WithDescription("cannot parse client update request").WithParent(err) } + req.ClientID = chi.URLParam(r, "client_id") // if there is a conflict, the client_id in the path takes precedence + return req, nil } @@ -436,10 +438,7 @@ func ParseClientDeleteRequest(r *http.Request, o OpenIDProvider) (*oidc.ClientDe r = r.WithContext(ctx) defer span.End() - req := new(oidc.ClientDeleteRequest) - if err := o.Decoder().Decode(req, r.Form); err != nil { - return nil, oidc.ErrInvalidRequest().WithDescription("cannot parse client delete request").WithParent(err) - } - - return req, nil + return &oidc.ClientDeleteRequest{ + ClientID: chi.URLParam(r, "client_id"), + }, nil } diff --git a/pkg/op/op.go b/pkg/op/op.go index a15fac21..0f0fc2d6 100644 --- a/pkg/op/op.go +++ b/pkg/op/op.go @@ -146,8 +146,8 @@ func CreateRouter(o OpenIDProvider, interceptors ...HttpInterceptor) chi.Router router.HandleFunc(o.EndSessionEndpoint().Relative(), endSessionHandler(o)) router.HandleFunc(o.KeysEndpoint().Relative(), keysHandler(o.Storage())) router.HandleFunc(o.DeviceAuthorizationEndpoint().Relative(), DeviceAuthorizationHandler(o)) - router.HandleFunc(o.RegistrationEndpoint().Relative(), clientRegistrationUpdateDeleteHandler(o)) - router.HandleFunc(path.Join(o.RegistrationEndpoint().Relative(), "{client_id}"), clientReadHandler(o)) + router.HandleFunc(o.RegistrationEndpoint().Relative(), clientRegistrationHandler(o)) + router.HandleFunc(path.Join(o.RegistrationEndpoint().Relative(), "{client_id}"), clientReadUpdateDeleteHandler(o)) return router } From 282620cf86a8e6b0690b0fd726996ac5561246a9 Mon Sep 17 00:00:00 2001 From: mqf20 Date: Sun, 28 Sep 2025 21:20:18 +0800 Subject: [PATCH 44/44] Update pkg/oidc/discovery.go Co-authored-by: lukaslihotzki-f <139768942+lukaslihotzki-f@users.noreply.github.com> --- pkg/oidc/discovery.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/oidc/discovery.go b/pkg/oidc/discovery.go index 7e01a928..9b3e5010 100644 --- a/pkg/oidc/discovery.go +++ b/pkg/oidc/discovery.go @@ -35,7 +35,7 @@ type DiscoveryConfiguration struct { // It may also contain the OP's encryption keys that RPs can use to encrypt request to the OP. JwksURI string `json:"jwks_uri,omitempty"` - // RegistrationEndpoint is the URL for the Dynamic Client Registration (RFC7591, RFC7592).. + // RegistrationEndpoint is the URL for the Dynamic Client Registration (RFC7591, RFC7592). RegistrationEndpoint string `json:"registration_endpoint,omitempty"` // ScopesSupported lists an array of supported scopes. This list must not include every supported scope by the OP.