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 7a9a8160..209e7f0c 100644 --- a/example/server/storage/storage.go +++ b/example/server/storage/storage.go @@ -6,6 +6,7 @@ import ( "crypto/rsa" "errors" "fmt" + "golang.org/x/text/language" "math/big" "strings" "sync" @@ -31,9 +32,10 @@ var serviceKey1 = &rsa.PublicKey{ var ( _ op.Storage = &Storage{} _ op.ClientCredentialsStorage = &Storage{} + _ 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 { @@ -931,3 +933,245 @@ func (s *Storage) ClientCredentialsTokenRequest(ctx context.Context, clientID st Scopes: scopes, }, nil } + +func (s *Storage) RegisterClient(_ context.Context, c *oidc.ClientRegistrationRequest) (*oidc.ClientRegistrationResponse, 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, + registrationAccessToken: uuid.New().String(), + } + s.clients[client.id] = &client + + return &oidc.ClientRegistrationResponse{ + ClientInformationResponse: oidc.ClientInformationResponse{ + ClientMetadata: oidc.ClientMetadata{ + RedirectURIs: client.redirectURIs, + TokenEndpointAuthMethod: client.authMethod, + GrantTypes: client.grantTypes, + ResponseTypes: client.responseTypes, + ClientName: oidc.InternationalizedField{ + FieldName: "client_name", + Entries: map[language.Tag]string{ + language.Und: 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: client.id, + ClientSecret: client.secret, + //ClientIDIssuedAt: 0, + //ClientSecretExpiresAt: 0, + }, + RegistrationAccessToken: client.registrationAccessToken, + RegistrationClientURI: "", + }, nil +} + +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.ClientReadResponse{ + ClientRegistrationResponse: oidc.ClientRegistrationResponse{ + ClientInformationResponse: oidc.ClientInformationResponse{ + ClientMetadata: oidc.ClientMetadata{ + RedirectURIs: client.redirectURIs, + TokenEndpointAuthMethod: client.authMethod, + GrantTypes: client.grantTypes, + ResponseTypes: client.responseTypes, + ClientName: oidc.InternationalizedField{ + FieldName: "client_name", + Entries: map[language.Tag]string{ + language.Und: 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: client.id, + ClientSecret: client.secret, + //ClientIDIssuedAt: 0, + //ClientSecretExpiresAt: 0, + }, + RegistrationAccessToken: client.registrationAccessToken, + RegistrationClientURI: "", + }, + }, nil +} + +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 nil, errors.New("client not found") + } + client.secret = c.ClientSecret + client.redirectURIs = c.RedirectURIs + client.authMethod = c.TokenEndpointAuthMethod + client.grantTypes = c.GrantTypes + client.responseTypes = c.ResponseTypes + + return &oidc.ClientInformationResponse{ + ClientMetadata: oidc.ClientMetadata{ + RedirectURIs: client.redirectURIs, + TokenEndpointAuthMethod: client.authMethod, + GrantTypes: client.grantTypes, + ResponseTypes: client.responseTypes, + ClientName: oidc.InternationalizedField{ + FieldName: "client_name", + Entries: map[language.Tag]string{ + language.Und: 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: client.id, + ClientSecret: client.secret, + //ClientIDIssuedAt: 0, + //ClientSecretExpiresAt: 0, + }, 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 +} + +func (s *Storage) AuthorizeClientRegistration(ctx context.Context, initialAccessToken string, c *oidc.ClientRegistrationRequest) error { + if initialAccessToken != "verysecure" { + 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 { + return s.authorizeClient(clientID, registrationAccessToken) +} + +func (s *Storage) AuthorizeClientUpdate(ctx context.Context, clientID, registrationAccessToken string) error { + return s.authorizeClient(clientID, registrationAccessToken) +} + +func (s *Storage) AuthorizeClientDelete(ctx context.Context, clientID, registrationAccessToken string) error { + return s.authorizeClient(clientID, registrationAccessToken) +} 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 62288d1b..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. + // 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. @@ -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 new file mode 100644 index 00000000..bd5bde68 --- /dev/null +++ b/pkg/oidc/dynamic_client_registration.go @@ -0,0 +1,1187 @@ +package oidc + +import ( + "encoding/json" + "errors" + "fmt" + "github.com/go-jose/go-jose/v4" + "strings" +) + +// 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. +// +// The Client Metadata values are used in two ways: +// +// - 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 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 + // 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 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. + // 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 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 + // 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 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, + // 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 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 + // 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 *ClientMetadata) UnmarshalJSON(data []byte) error { + // Initialize maps to avoid nil pointer issues later. + 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. + 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 rawMap { + switch { + case key == "redirect_uris": + if err := json.Unmarshal(value, &c.RedirectURIs); err != nil { + return err + } + case key == "token_endpoint_auth_method": + 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 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 err := json.Unmarshal(value, &c.ResponseTypes); err != nil { + // should we check against ResponseTypeMap if response_types is valid? + return err + } + case strings.HasPrefix(key, c.ClientName.FieldName): + 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 { + return err + } + case strings.HasPrefix(key, c.LogoURI.FieldName): + if err := c.LogoURI.insertEntry(key, value); err != nil { + return err + } + case key == "scope": + if err := json.Unmarshal(value, &c.Scope); err != nil { + return err + } + case key == "contacts": + if err := json.Unmarshal(value, &c.Contacts); err != nil { + return err + } + case strings.HasPrefix(key, c.TOSURI.FieldName): + 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 { + return err + } + case key == "jwks_uri": + if err := json.Unmarshal(value, &c.JWKSURI); err != nil { + return err + } + case key == "jwks": + if err := json.Unmarshal(value, &c.JWKS); err != nil { + return err + } + case key == "software_id": + if err := json.Unmarshal(value, &c.SoftwareID); err != nil { + return err + } + case key == "software_version": + if err := json.Unmarshal(value, &c.SoftwareVersion); err != nil { + return err + } + case key == "application_type": + if err := json.Unmarshal(value, &c.ApplicationType); err != nil { + return err + } + case key == "sector_identifier_uri": + if err := json.Unmarshal(value, &c.SectorIdentifierURI); err != nil { + return err + } + case key == "subject_type": + if err := json.Unmarshal(value, &c.SubjectType); err != nil { + return err + } + case key == "id_token_signed_response_alg": + if err := json.Unmarshal(value, &c.IDTokenSignedResponseAlg); err != nil { + return err + } + case key == "id_token_encrypted_response_alg": + if err := json.Unmarshal(value, &c.IDTokenEncryptedResponseAlg); err != nil { + return err + } + case key == "id_token_encrypted_response_enc": + if err := json.Unmarshal(value, &c.IDTokenEncryptedResponseEnc); err != nil { + return err + } + case key == "userinfo_signed_response_alg": + if err := json.Unmarshal(value, &c.UserinfoSignedResponseAlg); err != nil { + return err + } + case key == "userinfo_encrypted_response_alg": + if err := json.Unmarshal(value, &c.UserinfoEncryptedResponseAlg); err != nil { + return err + } + case key == "userinfo_encrypted_response_enc": + if err := json.Unmarshal(value, &c.UserinfoEncryptedResponseEnc); err != nil { + return err + } + case key == "request_object_signing_alg": + if err := json.Unmarshal(value, &c.RequestObjectEncryptionAlg); err != nil { + return err + } + case key == "request_object_encryption_alg": + if err := json.Unmarshal(value, &c.RequestObjectEncryptionAlg); err != nil { + return err + } + case key == "request_object_encryption_enc": + if err := json.Unmarshal(value, &c.RequestObjectEncryptionEnc); err != nil { + return err + } + case key == "token_endpoint_auth_signing_alg": + if err := json.Unmarshal(value, &c.TokenEndpointAuthSigningAlg); err != nil { + return err + } + case key == "default_max_age": + if err := json.Unmarshal(value, &c.DefaultMaxAge); err != nil { + return err + } + case key == "require_auth_time": + if err := json.Unmarshal(value, &c.RequireAuthTime); err != nil { + return err + } + case key == "default_acr_values": + if err := json.Unmarshal(value, &c.DefaultACRValues); err != nil { + return err + } + case key == "initiate_login_uri": + if err := json.Unmarshal(value, &c.InitiateLoginURI); err != nil { + return err + } + case key == "request_uris": + if err := json.Unmarshal(value, &c.RequestURIs); err != nil { + return err + } + case key == "post_logout_redirect_uris": + 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. + var val interface{} + if err := json.Unmarshal(value, &val); err != nil { + return err + } + c.ExtraParameters[key] = val + } + } + + // 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 + // 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 +} + +func (c ClientMetadata) MarshalJSON() ([]byte, error) { + res := make(map[string]interface{}) + + 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 + } + + c.ClientName.exportEntries(res) + c.ClientURI.exportEntries(res) + c.LogoURI.exportEntries(res) + + if c.Scope != "" { + res["scope"] = c.Scope + } + + if len(c.Contacts) > 0 { + res["contacts"] = c.Contacts + } + + c.TOSURI.exportEntries(res) + c.PolicyURI.exportEntries(res) + + 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.ApplicationType != "" { + res["application_type"] = c.ApplicationType + } + + 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 + + for key, value := range c.ExtraParameters { + res[key] = value + } + + 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 { + // 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 + } + + // 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 + } + + 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 + } + + 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 +// https://www.rfc-editor.org/rfc/rfc7591#section-3.2.1, +// 3.2.1. Client Information Response, +// https://www.rfc-editor.org/rfc/rfc7592.html#section-3 +// 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 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 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 + +// 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 +} + +// 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. +type ClientUpdateRequest struct { + ClientMetadata + + // 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"` +} + +func (c *ClientUpdateRequest) UnmarshalJSON(data []byte) error { + // 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 + } + + // 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 + } + + 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 + } + + // 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 +// 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 new file mode 100644 index 00000000..3fa57ae0 --- /dev/null +++ b/pkg/oidc/dynamic_client_registration_test.go @@ -0,0 +1,710 @@ +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" + "golang.org/x/text/language" + "math/big" + "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, + 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) { + wantJPTag, err := language.Parse("ja-Jpan-JP") + require.NoError(t, err) + 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(` +{ + "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) + 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.Entries, 2) + 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.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) + 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) { + 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-_AReZwBqfaWFcfGHrZXsIV2VMCNVNU8Tpb4obUaSXcRcQ-VMsfQPJm9IzgtRdAY8NN8Xb7PEcYyklBjvTtuPbpzIaqyiUepzUXNDFuAOOkrIol3WmflPUUgMKULBN0EUd1fpOD70pRM0rlp_gg_WNUKoW1V-3keYUJoXH9NztEDm_D2MQXj9eGOJJ8yPgGL8PAZMLe2R7jb9TxOCPDED7tY_TU4nFPlxptw59A42mldEmViXsKQt60s1SLboazxFKveqXC_jpLUt22OC6GUG63p-REw-ZOr3r845z50wMuzifQrMI9bQ", + "kty": "RSA" + }] + }, + "example_extension_parameter": "example_value" +} +`) + 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.Entries, 2) + 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.GetDefaultEntry()) + 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) { + marshalled := []byte(` +{ + "redirect_uris": [ + "https://client.example.org/callback", + "https://client.example.org/callback2" + ], + "software_statement": "eyJhbGciOiJSUzI1NiJ9.eyJzb2Z0d2FyZV9pZCI6IjROUkIxLTBYWkFCWkk5RTYtNVNNM1IiLCJjbGllbnRfbmFtZSI6IkV4YW1wbGUgU3RhdGVtZW50LWJhc2VkIENsaWVudCIsImNsaWVudF91cmkiOiJodHRwczovL2NsaWVudC5leGFtcGxlLm5ldC8ifQ.GHfL4QNIrQwL18BSRdE595T9jbzqa06R9BT8w409x9oIcKaZo_mt15riEXHazdISUvDIZhtiyNrSHQ8K4TvqWxH6uJgcmoodZdPwmWRIEYbQDLqPNxREtYn05X3AR7ia4FRjQ2ojZjk5fJqJdQ-JcfxyhK-P8BAWBd6I2LLA77IG32xtbhxYfHX7VhuU5ProJO8uvu3Ayv4XRhLZJY4yKfmyjiiKiPNe-Ia4SMy_d_QSWxskU5XIQl5Sa2YRPMbDRXttm2TfnZM1xx70DoYi8g6czz-CPGRi4SW_S2RKHIJfIjoI3zTJ0Y2oe0_EJAiXbL6OyF9S5tKxDXV8JIndSA", + "scope": "read write", + "example_extension_parameter": "example_value" +} +`) + 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"]) + }) + // 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(` +{ + "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", + "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 ClientRegistrationRequest + err := json.Unmarshal(marshalled, &req) + require.NoError(t, err) + 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/callback2") + assert.Len(t, req.ClientName.Entries, 2) + 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.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) + assert.Equal(t, "https://client.example.org/my_public_keys.jwks", req.JWKSURI) + 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) { + 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 := ` +{ + "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"] +} +` + 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: InternationalizedField{ + FieldName: "client_name", + Entries: map[language.Tag]string{ + language.Und: "My Example", + wantJPTag: "クライアント名", + }, + }, + //ClientURI: InternationalizedField{}, + LogoURI: InternationalizedField{ + FieldName: "logo_uri", + Entries: map[language.Tag]string{ + language.Und: "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.JSONEq(t, want, string(marshalled)) + }) +} + +func TestClientInformationErrorResponse(t *testing.T) { + // example from https://www.rfc-editor.org/rfc/rfc7591#page-23 + 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." +} +` + 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.JSONEq(t, want, string(marshalled)) + }) + // example from https://www.rfc-editor.org/rfc/rfc7591#page-23 + 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." +} +` + 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.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) { + want := ` + { + "error": "invalid_redirect_uri", + "error_description": "One or more redirect_uri values are invalid" +} +` + res := ClientInformationErrorResponse{ + Error: ClientInformationErrorResponseErrorCodeInvalidRedirectURI, + ErrorDescription: "One or more redirect_uri values are invalid", + } + marshalled, err := json.Marshal(res) + require.NoError(t, err) + + assert.JSONEq(t, want, string(marshalled)) + }) +} + +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 := ` +{ + "client_id": "s6BhdRkqt3", + "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", + "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"] +} +` + 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: InternationalizedField{ + FieldName: "client_name", + Entries: map[language.Tag]string{ + language.Und: "My Example", + wantJPTag: "クライアント名", + }, + }, + //ClientURI: nil, + LogoURI: InternationalizedField{ + FieldName: "logo_uri", + Entries: map[language.Tag]string{ + language.Und: "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.JSONEq(t, want, string(marshalled)) + }) + // example from https://www.rfc-editor.org/rfc/rfc7591#page-21 + t.Run("marshal example", func(t *testing.T) { + want := ` +{ + "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" +} +` + 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: InternationalizedField{ + FieldName: "client_name", + Entries: map[language.Tag]string{ + language.Und: "My Example Client", + wantJPTag: "クライアント名", + }, + }, + //ClientURI: nil, + LogoURI: InternationalizedField{ + FieldName: "logo_uri", + Entries: map[language.Tag]string{ + language.Und: "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.JSONEq(t, want, string(marshalled)) + }) + + // example from https://www.rfc-editor.org/rfc/rfc7592.html#page-11 + 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", + "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" +} +` + 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: InternationalizedField{ + FieldName: "client_name", + Entries: map[language.Tag]string{ + language.Und: "My Example Client", + wantJPTag: "クライアント名", + }, + }, + //ClientURI: nil, + LogoURI: InternationalizedField{ + FieldName: "logo_uri", + Entries: map[language.Tag]string{ + language.Und: "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.JSONEq(t, want, string(marshalled)) + }) +} + +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(` +{ + "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 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.Len(t, req.ClientName.Entries, 2) + 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.GetDefaultEntry()) + assert.Equal(t, "https://client.example.org/fr/newlogo.png", req.LogoURI.GetEntry(language.French)) + }) +} diff --git a/pkg/oidc/internationalized_field.go b/pkg/oidc/internationalized_field.go new file mode 100644 index 00000000..7d1b2bdc --- /dev/null +++ b/pkg/oidc/internationalized_field.go @@ -0,0 +1,91 @@ +package oidc + +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 + Entries languageMap +} + +func NewInternationalizedField(fieldName string) InternationalizedField { + return InternationalizedField{ + FieldName: fieldName, + 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 + } + } +} + +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] +} 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 ( 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..8a0c98d6 --- /dev/null +++ b/pkg/op/dynamic_client_registration.go @@ -0,0 +1,444 @@ +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" + "log/slog" + "net/http" + "strings" +) + +var ( + errMissingAuthorizationHeader = errors.New("missing authorization header") + 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 == "" { + return "", errMissingAuthorizationHeader + } + if !strings.HasPrefix(auth, oidc.PrefixBearer) { + return "", errInvalidHeader + } + 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 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()) + } + } +} + +// 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) { + ctx, span := tracer.Start(r.Context(), "clientRead") + r = r.WithContext(ctx) + defer span.End() + + storage, err := assertClientStorage(o.Storage()) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + req, err := ParseClientReadRequest(r, o) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + registrationAccessToken, err := getBearerToken(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if err := storage.AuthorizeClientRead(ctx, req.ClientID, registrationAccessToken); err != nil { + 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 { + 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 +} + +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) + } + + req.ClientID = chi.URLParam(r, "client_id") + return req, nil +} + +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) + 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) { + ctx, span := tracer.Start(r.Context(), "clientRegistration") + r = r.WithContext(ctx) + defer span.End() + + storage, err := assertClientStorage(o.Storage()) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + req, err := ParseClientRegistrationRequest(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + 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 + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + initialAccessToken = iat + } + + if err := storage.AuthorizeClientRegistration(ctx, initialAccessToken, req); err != nil { + 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 { + 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 + // 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 +} + +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 := 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 +// [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) { + ctx, span := tracer.Start(r.Context(), "clientUpdate") + r = r.WithContext(ctx) + defer span.End() + + storage, err := assertClientStorage(o.Storage()) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + req, err := ParseClientUpdateRequest(r, o) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + registrationAccessToken, err := getBearerToken(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if err := storage.AuthorizeClientUpdate(ctx, req.ClientID, registrationAccessToken); err != nil { + 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 { + 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 +} + +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 := 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 +} + +// 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) { + ctx, span := tracer.Start(r.Context(), "clientDelete") + r = r.WithContext(ctx) + defer span.End() + + storage, err := assertClientStorage(o.Storage()) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + req, err := ParseClientDeleteRequest(r, o) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + registrationAccessToken, err := getBearerToken(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if err := storage.AuthorizeClientDelete(ctx, req.ClientID, registrationAccessToken); err != nil { + 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 { + 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 +} + +func ParseClientDeleteRequest(r *http.Request, o OpenIDProvider) (*oidc.ClientDeleteRequest, error) { + ctx, span := tracer.Start(r.Context(), "ParseClientDeleteRequest") + r = r.WithContext(ctx) + defer span.End() + + return &oidc.ClientDeleteRequest{ + ClientID: chi.URLParam(r, "client_id"), + }, nil +} 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)) +} diff --git a/pkg/op/op.go b/pkg/op/op.go index 62323504..a3989253 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" @@ -30,6 +31,7 @@ const ( defaultEndSessionEndpoint = "end_session" defaultKeysEndpoint = "keys" defaultDeviceAuthzEndpoint = "/device_authorization" + defaultRegistrationEndpoint = "oauth/register" ) var ( @@ -42,6 +44,7 @@ var ( EndSession: NewEndpoint(defaultEndSessionEndpoint), JwksURI: NewEndpoint(defaultKeysEndpoint), DeviceAuthorization: NewEndpoint(defaultDeviceAuthzEndpoint), + Registration: NewEndpoint(defaultRegistrationEndpoint), } DefaultSupportedClaims = []string{ @@ -143,6 +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(), clientRegistrationHandler(o)) + router.HandleFunc(path.Join(o.RegistrationEndpoint().Relative(), "{client_id}"), clientReadUpdateDeleteHandler(o)) return router } @@ -184,6 +189,7 @@ type Endpoints struct { CheckSessionIframe *Endpoint JwksURI *Endpoint DeviceAuthorization *Endpoint + Registration *Endpoint } // NewOpenIDProvider creates a provider. The provider provides (with HttpHandler()) @@ -343,6 +349,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 +597,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. 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 35b07694..04c0e2b6 100644 --- a/pkg/op/server_http_routes_test.go +++ b/pkg/op/server_http_routes_test.go @@ -106,7 +106,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", diff --git a/pkg/op/storage.go b/pkg/op/storage.go index 5c64989a..cd0d7424 100644 --- a/pkg/op/storage.go +++ b/pkg/op/storage.go @@ -157,10 +157,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 @@ -206,3 +210,133 @@ 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 + + // 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.) + // + // 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 + + // 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 +} + +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 +}