mirror of
https://github.com/coder/coder.git
synced 2025-07-12 00:14:10 +00:00
# Implement OAuth2 Dynamic Client Registration (RFC 7591/7592) This PR implements OAuth2 Dynamic Client Registration according to RFC 7591 and Client Configuration Management according to RFC 7592. These standards allow OAuth2 clients to register themselves programmatically with Coder as an authorization server. Key changes include: 1. Added database schema extensions to support RFC 7591/7592 fields in the `oauth2_provider_apps` table 2. Implemented `/oauth2/register` endpoint for dynamic client registration (RFC 7591) 3. Added client configuration management endpoints (RFC 7592): - GET/PUT/DELETE `/oauth2/clients/{client_id}` - Registration access token validation middleware 4. Added comprehensive validation for OAuth2 client metadata: - URI validation with support for custom schemes for native apps - Grant type and response type validation - Token endpoint authentication method validation 5. Enhanced developer documentation with: - RFC compliance guidelines - Testing best practices to avoid race conditions - Systematic debugging approaches for OAuth2 implementations The implementation follows security best practices from the RFCs, including proper token handling, secure defaults, and appropriate error responses. This enables third-party applications to integrate with Coder's OAuth2 provider capabilities programmatically.
470 lines
18 KiB
Go
470 lines
18 KiB
Go
package codersdk
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
type OAuth2ProviderApp struct {
|
|
ID uuid.UUID `json:"id" format:"uuid"`
|
|
Name string `json:"name"`
|
|
CallbackURL string `json:"callback_url"`
|
|
Icon string `json:"icon"`
|
|
|
|
// Endpoints are included in the app response for easier discovery. The OAuth2
|
|
// spec does not have a defined place to find these (for comparison, OIDC has
|
|
// a '/.well-known/openid-configuration' endpoint).
|
|
Endpoints OAuth2AppEndpoints `json:"endpoints"`
|
|
}
|
|
|
|
type OAuth2AppEndpoints struct {
|
|
Authorization string `json:"authorization"`
|
|
Token string `json:"token"`
|
|
// DeviceAuth is optional.
|
|
DeviceAuth string `json:"device_authorization"`
|
|
}
|
|
|
|
type OAuth2ProviderAppFilter struct {
|
|
UserID uuid.UUID `json:"user_id,omitempty" format:"uuid"`
|
|
}
|
|
|
|
// OAuth2ProviderApps returns the applications configured to authenticate using
|
|
// Coder as an OAuth2 provider.
|
|
func (c *Client) OAuth2ProviderApps(ctx context.Context, filter OAuth2ProviderAppFilter) ([]OAuth2ProviderApp, error) {
|
|
res, err := c.Request(ctx, http.MethodGet, "/api/v2/oauth2-provider/apps", nil,
|
|
func(r *http.Request) {
|
|
if filter.UserID != uuid.Nil {
|
|
q := r.URL.Query()
|
|
q.Set("user_id", filter.UserID.String())
|
|
r.URL.RawQuery = q.Encode()
|
|
}
|
|
})
|
|
if err != nil {
|
|
return []OAuth2ProviderApp{}, err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return []OAuth2ProviderApp{}, ReadBodyAsError(res)
|
|
}
|
|
var apps []OAuth2ProviderApp
|
|
return apps, json.NewDecoder(res.Body).Decode(&apps)
|
|
}
|
|
|
|
// OAuth2ProviderApp returns an application configured to authenticate using
|
|
// Coder as an OAuth2 provider.
|
|
func (c *Client) OAuth2ProviderApp(ctx context.Context, id uuid.UUID) (OAuth2ProviderApp, error) {
|
|
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/oauth2-provider/apps/%s", id), nil)
|
|
if err != nil {
|
|
return OAuth2ProviderApp{}, err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return OAuth2ProviderApp{}, ReadBodyAsError(res)
|
|
}
|
|
var apps OAuth2ProviderApp
|
|
return apps, json.NewDecoder(res.Body).Decode(&apps)
|
|
}
|
|
|
|
type PostOAuth2ProviderAppRequest struct {
|
|
Name string `json:"name" validate:"required,oauth2_app_name"`
|
|
CallbackURL string `json:"callback_url" validate:"required,http_url"`
|
|
Icon string `json:"icon" validate:"omitempty"`
|
|
}
|
|
|
|
// PostOAuth2ProviderApp adds an application that can authenticate using Coder
|
|
// as an OAuth2 provider.
|
|
func (c *Client) PostOAuth2ProviderApp(ctx context.Context, app PostOAuth2ProviderAppRequest) (OAuth2ProviderApp, error) {
|
|
res, err := c.Request(ctx, http.MethodPost, "/api/v2/oauth2-provider/apps", app)
|
|
if err != nil {
|
|
return OAuth2ProviderApp{}, err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusCreated {
|
|
return OAuth2ProviderApp{}, ReadBodyAsError(res)
|
|
}
|
|
var resp OAuth2ProviderApp
|
|
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
|
}
|
|
|
|
type PutOAuth2ProviderAppRequest struct {
|
|
Name string `json:"name" validate:"required,oauth2_app_name"`
|
|
CallbackURL string `json:"callback_url" validate:"required,http_url"`
|
|
Icon string `json:"icon" validate:"omitempty"`
|
|
}
|
|
|
|
// PutOAuth2ProviderApp updates an application that can authenticate using Coder
|
|
// as an OAuth2 provider.
|
|
func (c *Client) PutOAuth2ProviderApp(ctx context.Context, id uuid.UUID, app PutOAuth2ProviderAppRequest) (OAuth2ProviderApp, error) {
|
|
res, err := c.Request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/oauth2-provider/apps/%s", id), app)
|
|
if err != nil {
|
|
return OAuth2ProviderApp{}, err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return OAuth2ProviderApp{}, ReadBodyAsError(res)
|
|
}
|
|
var resp OAuth2ProviderApp
|
|
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
|
}
|
|
|
|
// DeleteOAuth2ProviderApp deletes an application, also invalidating any tokens
|
|
// that were generated from it.
|
|
func (c *Client) DeleteOAuth2ProviderApp(ctx context.Context, id uuid.UUID) error {
|
|
res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/oauth2-provider/apps/%s", id), nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusNoContent {
|
|
return ReadBodyAsError(res)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type OAuth2ProviderAppSecretFull struct {
|
|
ID uuid.UUID `json:"id" format:"uuid"`
|
|
ClientSecretFull string `json:"client_secret_full"`
|
|
}
|
|
|
|
type OAuth2ProviderAppSecret struct {
|
|
ID uuid.UUID `json:"id" format:"uuid"`
|
|
LastUsedAt NullTime `json:"last_used_at"`
|
|
ClientSecretTruncated string `json:"client_secret_truncated"`
|
|
}
|
|
|
|
// OAuth2ProviderAppSecrets returns the truncated secrets for an OAuth2
|
|
// application.
|
|
func (c *Client) OAuth2ProviderAppSecrets(ctx context.Context, appID uuid.UUID) ([]OAuth2ProviderAppSecret, error) {
|
|
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/oauth2-provider/apps/%s/secrets", appID), nil)
|
|
if err != nil {
|
|
return []OAuth2ProviderAppSecret{}, err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return []OAuth2ProviderAppSecret{}, ReadBodyAsError(res)
|
|
}
|
|
var resp []OAuth2ProviderAppSecret
|
|
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
|
}
|
|
|
|
// PostOAuth2ProviderAppSecret creates a new secret for an OAuth2 application.
|
|
// This is the only time the full secret will be revealed.
|
|
func (c *Client) PostOAuth2ProviderAppSecret(ctx context.Context, appID uuid.UUID) (OAuth2ProviderAppSecretFull, error) {
|
|
res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/oauth2-provider/apps/%s/secrets", appID), nil)
|
|
if err != nil {
|
|
return OAuth2ProviderAppSecretFull{}, err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusCreated {
|
|
return OAuth2ProviderAppSecretFull{}, ReadBodyAsError(res)
|
|
}
|
|
var resp OAuth2ProviderAppSecretFull
|
|
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
|
}
|
|
|
|
// DeleteOAuth2ProviderAppSecret deletes a secret from an OAuth2 application,
|
|
// also invalidating any tokens that generated from it.
|
|
func (c *Client) DeleteOAuth2ProviderAppSecret(ctx context.Context, appID uuid.UUID, secretID uuid.UUID) error {
|
|
res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/oauth2-provider/apps/%s/secrets/%s", appID, secretID), nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusNoContent {
|
|
return ReadBodyAsError(res)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type OAuth2ProviderGrantType string
|
|
|
|
const (
|
|
OAuth2ProviderGrantTypeAuthorizationCode OAuth2ProviderGrantType = "authorization_code"
|
|
OAuth2ProviderGrantTypeRefreshToken OAuth2ProviderGrantType = "refresh_token"
|
|
)
|
|
|
|
func (e OAuth2ProviderGrantType) Valid() bool {
|
|
switch e {
|
|
case OAuth2ProviderGrantTypeAuthorizationCode, OAuth2ProviderGrantTypeRefreshToken:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
type OAuth2ProviderResponseType string
|
|
|
|
const (
|
|
OAuth2ProviderResponseTypeCode OAuth2ProviderResponseType = "code"
|
|
)
|
|
|
|
func (e OAuth2ProviderResponseType) Valid() bool {
|
|
//nolint:gocritic,revive // More cases might be added later.
|
|
switch e {
|
|
case OAuth2ProviderResponseTypeCode:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// RevokeOAuth2ProviderApp completely revokes an app's access for the
|
|
// authenticated user.
|
|
func (c *Client) RevokeOAuth2ProviderApp(ctx context.Context, appID uuid.UUID) error {
|
|
res, err := c.Request(ctx, http.MethodDelete, "/oauth2/tokens", nil, func(r *http.Request) {
|
|
q := r.URL.Query()
|
|
q.Set("client_id", appID.String())
|
|
r.URL.RawQuery = q.Encode()
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusNoContent {
|
|
return ReadBodyAsError(res)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type OAuth2DeviceFlowCallbackResponse struct {
|
|
RedirectURL string `json:"redirect_url"`
|
|
}
|
|
|
|
// OAuth2AuthorizationServerMetadata represents RFC 8414 OAuth 2.0 Authorization Server Metadata
|
|
type OAuth2AuthorizationServerMetadata struct {
|
|
Issuer string `json:"issuer"`
|
|
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
|
TokenEndpoint string `json:"token_endpoint"`
|
|
RegistrationEndpoint string `json:"registration_endpoint,omitempty"`
|
|
ResponseTypesSupported []string `json:"response_types_supported"`
|
|
GrantTypesSupported []string `json:"grant_types_supported"`
|
|
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
|
|
ScopesSupported []string `json:"scopes_supported,omitempty"`
|
|
TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported,omitempty"`
|
|
}
|
|
|
|
// OAuth2ProtectedResourceMetadata represents RFC 9728 OAuth 2.0 Protected Resource Metadata
|
|
type OAuth2ProtectedResourceMetadata struct {
|
|
Resource string `json:"resource"`
|
|
AuthorizationServers []string `json:"authorization_servers"`
|
|
ScopesSupported []string `json:"scopes_supported,omitempty"`
|
|
BearerMethodsSupported []string `json:"bearer_methods_supported,omitempty"`
|
|
}
|
|
|
|
// OAuth2ClientRegistrationRequest represents RFC 7591 Dynamic Client Registration Request
|
|
type OAuth2ClientRegistrationRequest struct {
|
|
RedirectURIs []string `json:"redirect_uris,omitempty"`
|
|
ClientName string `json:"client_name,omitempty"`
|
|
ClientURI string `json:"client_uri,omitempty"`
|
|
LogoURI string `json:"logo_uri,omitempty"`
|
|
TOSURI string `json:"tos_uri,omitempty"`
|
|
PolicyURI string `json:"policy_uri,omitempty"`
|
|
JWKSURI string `json:"jwks_uri,omitempty"`
|
|
JWKS json.RawMessage `json:"jwks,omitempty" swaggertype:"object"`
|
|
SoftwareID string `json:"software_id,omitempty"`
|
|
SoftwareVersion string `json:"software_version,omitempty"`
|
|
SoftwareStatement string `json:"software_statement,omitempty"`
|
|
GrantTypes []string `json:"grant_types,omitempty"`
|
|
ResponseTypes []string `json:"response_types,omitempty"`
|
|
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
|
|
Scope string `json:"scope,omitempty"`
|
|
Contacts []string `json:"contacts,omitempty"`
|
|
}
|
|
|
|
func (req OAuth2ClientRegistrationRequest) ApplyDefaults() OAuth2ClientRegistrationRequest {
|
|
// Apply grant type defaults
|
|
if len(req.GrantTypes) == 0 {
|
|
req.GrantTypes = []string{
|
|
string(OAuth2ProviderGrantTypeAuthorizationCode),
|
|
string(OAuth2ProviderGrantTypeRefreshToken),
|
|
}
|
|
}
|
|
|
|
// Apply response type defaults
|
|
if len(req.ResponseTypes) == 0 {
|
|
req.ResponseTypes = []string{
|
|
string(OAuth2ProviderResponseTypeCode),
|
|
}
|
|
}
|
|
|
|
// Apply token endpoint auth method default (RFC 7591 section 2)
|
|
if req.TokenEndpointAuthMethod == "" {
|
|
// Default according to RFC 7591: "client_secret_basic" for confidential clients
|
|
// For public clients, should be explicitly set to "none"
|
|
req.TokenEndpointAuthMethod = "client_secret_basic"
|
|
}
|
|
|
|
// Apply client name default if not provided
|
|
if req.ClientName == "" {
|
|
req.ClientName = "Dynamically Registered Client"
|
|
}
|
|
|
|
return req
|
|
}
|
|
|
|
// DetermineClientType determines if client is public or confidential
|
|
func (*OAuth2ClientRegistrationRequest) DetermineClientType() string {
|
|
// For now, default to confidential
|
|
// In the future, we might detect based on:
|
|
// - token_endpoint_auth_method == "none" -> public
|
|
// - application_type == "native" -> might be public
|
|
// - Other heuristics
|
|
return "confidential"
|
|
}
|
|
|
|
// GenerateClientName generates a client name if not provided
|
|
func (req *OAuth2ClientRegistrationRequest) GenerateClientName() string {
|
|
if req.ClientName != "" {
|
|
// Ensure client name fits database constraint (varchar(64))
|
|
if len(req.ClientName) > 64 {
|
|
// Preserve uniqueness by including a hash of the original name
|
|
hash := fmt.Sprintf("%x", sha256.Sum256([]byte(req.ClientName)))[:8]
|
|
maxPrefix := 64 - 1 - len(hash) // 1 for separator
|
|
return req.ClientName[:maxPrefix] + "-" + hash
|
|
}
|
|
return req.ClientName
|
|
}
|
|
|
|
// Try to derive from client_uri
|
|
if req.ClientURI != "" {
|
|
if uri, err := url.Parse(req.ClientURI); err == nil && uri.Host != "" {
|
|
name := fmt.Sprintf("Client (%s)", uri.Host)
|
|
if len(name) > 64 {
|
|
return name[:64]
|
|
}
|
|
return name
|
|
}
|
|
}
|
|
|
|
// Try to derive from first redirect URI
|
|
if len(req.RedirectURIs) > 0 {
|
|
if uri, err := url.Parse(req.RedirectURIs[0]); err == nil && uri.Host != "" {
|
|
name := fmt.Sprintf("Client (%s)", uri.Host)
|
|
if len(name) > 64 {
|
|
return name[:64]
|
|
}
|
|
return name
|
|
}
|
|
}
|
|
|
|
return "Dynamically Registered Client"
|
|
}
|
|
|
|
// OAuth2ClientRegistrationResponse represents RFC 7591 Dynamic Client Registration Response
|
|
type OAuth2ClientRegistrationResponse struct {
|
|
ClientID string `json:"client_id"`
|
|
ClientSecret string `json:"client_secret,omitempty"`
|
|
ClientIDIssuedAt int64 `json:"client_id_issued_at"`
|
|
ClientSecretExpiresAt int64 `json:"client_secret_expires_at,omitempty"`
|
|
RedirectURIs []string `json:"redirect_uris,omitempty"`
|
|
ClientName string `json:"client_name,omitempty"`
|
|
ClientURI string `json:"client_uri,omitempty"`
|
|
LogoURI string `json:"logo_uri,omitempty"`
|
|
TOSURI string `json:"tos_uri,omitempty"`
|
|
PolicyURI string `json:"policy_uri,omitempty"`
|
|
JWKSURI string `json:"jwks_uri,omitempty"`
|
|
JWKS json.RawMessage `json:"jwks,omitempty" swaggertype:"object"`
|
|
SoftwareID string `json:"software_id,omitempty"`
|
|
SoftwareVersion string `json:"software_version,omitempty"`
|
|
GrantTypes []string `json:"grant_types"`
|
|
ResponseTypes []string `json:"response_types"`
|
|
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`
|
|
Scope string `json:"scope,omitempty"`
|
|
Contacts []string `json:"contacts,omitempty"`
|
|
RegistrationAccessToken string `json:"registration_access_token"`
|
|
RegistrationClientURI string `json:"registration_client_uri"`
|
|
}
|
|
|
|
// PostOAuth2ClientRegistration dynamically registers a new OAuth2 client (RFC 7591)
|
|
func (c *Client) PostOAuth2ClientRegistration(ctx context.Context, req OAuth2ClientRegistrationRequest) (OAuth2ClientRegistrationResponse, error) {
|
|
res, err := c.Request(ctx, http.MethodPost, "/oauth2/register", req)
|
|
if err != nil {
|
|
return OAuth2ClientRegistrationResponse{}, err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusCreated {
|
|
return OAuth2ClientRegistrationResponse{}, ReadBodyAsError(res)
|
|
}
|
|
var resp OAuth2ClientRegistrationResponse
|
|
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
|
}
|
|
|
|
// GetOAuth2ClientConfiguration retrieves client configuration (RFC 7592)
|
|
func (c *Client) GetOAuth2ClientConfiguration(ctx context.Context, clientID string, registrationAccessToken string) (OAuth2ClientConfiguration, error) {
|
|
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/oauth2/clients/%s", clientID), nil,
|
|
func(r *http.Request) {
|
|
r.Header.Set("Authorization", "Bearer "+registrationAccessToken)
|
|
})
|
|
if err != nil {
|
|
return OAuth2ClientConfiguration{}, err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return OAuth2ClientConfiguration{}, ReadBodyAsError(res)
|
|
}
|
|
var resp OAuth2ClientConfiguration
|
|
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
|
}
|
|
|
|
// PutOAuth2ClientConfiguration updates client configuration (RFC 7592)
|
|
func (c *Client) PutOAuth2ClientConfiguration(ctx context.Context, clientID string, registrationAccessToken string, req OAuth2ClientRegistrationRequest) (OAuth2ClientConfiguration, error) {
|
|
res, err := c.Request(ctx, http.MethodPut, fmt.Sprintf("/oauth2/clients/%s", clientID), req,
|
|
func(r *http.Request) {
|
|
r.Header.Set("Authorization", "Bearer "+registrationAccessToken)
|
|
})
|
|
if err != nil {
|
|
return OAuth2ClientConfiguration{}, err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return OAuth2ClientConfiguration{}, ReadBodyAsError(res)
|
|
}
|
|
var resp OAuth2ClientConfiguration
|
|
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
|
}
|
|
|
|
// DeleteOAuth2ClientConfiguration deletes client registration (RFC 7592)
|
|
func (c *Client) DeleteOAuth2ClientConfiguration(ctx context.Context, clientID string, registrationAccessToken string) error {
|
|
res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/oauth2/clients/%s", clientID), nil,
|
|
func(r *http.Request) {
|
|
r.Header.Set("Authorization", "Bearer "+registrationAccessToken)
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusNoContent {
|
|
return ReadBodyAsError(res)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// OAuth2ClientConfiguration represents RFC 7592 Client Configuration (for GET/PUT operations)
|
|
// Same as OAuth2ClientRegistrationResponse but without client_secret in GET responses
|
|
type OAuth2ClientConfiguration struct {
|
|
ClientID string `json:"client_id"`
|
|
ClientIDIssuedAt int64 `json:"client_id_issued_at"`
|
|
ClientSecretExpiresAt int64 `json:"client_secret_expires_at,omitempty"`
|
|
RedirectURIs []string `json:"redirect_uris,omitempty"`
|
|
ClientName string `json:"client_name,omitempty"`
|
|
ClientURI string `json:"client_uri,omitempty"`
|
|
LogoURI string `json:"logo_uri,omitempty"`
|
|
TOSURI string `json:"tos_uri,omitempty"`
|
|
PolicyURI string `json:"policy_uri,omitempty"`
|
|
JWKSURI string `json:"jwks_uri,omitempty"`
|
|
JWKS json.RawMessage `json:"jwks,omitempty" swaggertype:"object"`
|
|
SoftwareID string `json:"software_id,omitempty"`
|
|
SoftwareVersion string `json:"software_version,omitempty"`
|
|
GrantTypes []string `json:"grant_types"`
|
|
ResponseTypes []string `json:"response_types"`
|
|
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`
|
|
Scope string `json:"scope,omitempty"`
|
|
Contacts []string `json:"contacts,omitempty"`
|
|
RegistrationAccessToken string `json:"registration_access_token"`
|
|
RegistrationClientURI string `json:"registration_client_uri"`
|
|
}
|