In modern OAuth 2.0 and OpenID Connect ecosystems, client applications need a standardized way to register with authorization servers dynamically. Dynamic Client Registration (DCR), defined in RFC 7591 and RFC 7592, enables clients to register themselves programmatically, providing metadata about their capabilities, redirect URIs, and security requirements. For Java applications, implementing DCR allows for scalable client management, seamless integration with third-party applications, and compliance with open banking and financial-grade API requirements.
What is Dynamic Client Registration?
Dynamic Client Registration is an OAuth 2.0 protocol extension that allows client applications to register with an authorization server via a REST API. The registration process includes:
- Client Metadata: Information about the client (name, redirect URIs, grant types)
- Software Statements: Signed assertions about the client's software
- Initial Access Tokens: Optional tokens to authorize registration
- Registration Response: Client ID, secrets, and registration metadata
Why DCR Matters for Java Applications
- Scalability: Automate client onboarding without manual configuration
- Third-Party Integration: Allow external applications to register securely
- Open Banking: Required for PSD2, Open Banking, and CDR compliance
- Dynamic Environments: Support for multi-tenant and microservice architectures
- Self-Service: Enable developers to register applications through portals
DCR Flow Overview
┌─────────┐ ┌──────────────┐ ┌─────────┐ │ Client │ │ Auth │ │ Client │ │(Dev) │ │ Server │ │(App) │ └────┬────┘ └──────┬───────┘ └────┬────┘ │ │ │ │ 1. Initial Access │ │ │ Token (optional) │ │ ├─────────────────────>│ │ │ │ │ │ 2. Registration │ │ │ Request │ │ ├─────────────────────>│ │ │ │ │ │ 3. Validate │ │ │ Metadata │ │ │<─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─│ │ │ │ │ │ 4. Registration │ │ │ Response │ │ │<─────────────────────┤ │ │ │ │ │ 5. Use Client │ │ │ Credentials │ │ ├─────────────────────────────────────────────>│ │ │ │ ┌────┴────┐ ┌──────┴───────┐ ┌────┴────┐ │ Client │ │ Auth │ │ Client │ │(Dev) │ │ Server │ │(App) │ └─────────┘ └──────────────┘ └─────────┘
Implementing DCR in Java with Spring Boot
1. Maven Dependencies
<dependencies> <!-- Spring Boot Web --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- Spring Security --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <!-- JPA for client storage --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <!-- Validation --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <!-- Nimbus JOSE for JWT handling --> <dependency> <groupId>com.nimbusds</groupId> <artifactId>nimbus-jose-jwt</artifactId> <version>9.37</version> </dependency> <!-- JSON processing --> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> </dependency> <!-- H2 Database for development --> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> </dependency> </dependencies>
2. JPA Entity for Registered Clients
import javax.persistence.*;
import java.time.Instant;
import java.util.List;
import java.util.Map;
@Entity
@Table(name = "oauth_clients")
public class OAuthClient {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String clientId;
@Column(nullable = false)
private String clientSecret;
@Column(nullable = false)
private String clientName;
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "client_redirect_uris",
joinColumns = @JoinColumn(name = "client_id"))
@Column(name = "redirect_uri")
private List<String> redirectUris;
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "client_grant_types",
joinColumns = @JoinColumn(name = "client_id"))
@Column(name = "grant_type")
private List<String> grantTypes;
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "client_response_types",
joinColumns = @JoinColumn(name = "client_id"))
@Column(name = "response_type")
private List<String> responseTypes;
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "client_scopes",
joinColumns = @JoinColumn(name = "client_id"))
@Column(name = "scope")
private List<String> scopes;
@Column(length = 5000)
private String jwks;
private String jwksUri;
private String tokenEndpointAuthMethod;
private String tokenEndpointAuthSigningAlg;
private String requestObjectSigningAlg;
private String softwareId;
private String softwareVersion;
private String softwareStatement;
@Convert(converter = InstantConverter.class)
private Instant createdAt;
@Convert(converter = InstantConverter.class)
private Instant clientIdIssuedAt;
@Convert(converter = InstantConverter.class)
private Instant clientSecretExpiresAt;
private String registrationAccessToken;
private String registrationClientUri;
@Convert(converter = JsonConverter.class)
private Map<String, Object> additionalMetadata;
// Getters and setters
}
3. DCR Request and Response Models
import lombok.Data;
import lombok.Builder;
import javax.validation.constraints.*;
import java.util.List;
import java.util.Map;
@Data
@Builder
public class ClientRegistrationRequest {
@NotBlank(message = "client_name is required")
private String clientName;
@NotEmpty(message = "redirect_uris must contain at least one URI")
@Valid
private List<@NotBlank @Pattern(regexp = "^https?://.*$") String> redirectUris;
private List<String> grantTypes;
private List<String> responseTypes;
private String scope;
private String jwksUri;
private String jwks;
private String tokenEndpointAuthMethod;
private String tokenEndpointAuthSigningAlg;
private String requestObjectSigningAlg;
private String softwareId;
private String softwareVersion;
private String softwareStatement;
private Map<String, Object> metadata;
}
@Data
@Builder
public class ClientRegistrationResponse {
@NotBlank
private String clientId;
@NotBlank
private String clientSecret;
@NotNull
private Long clientIdIssuedAt;
private Long clientSecretExpiresAt;
@NotBlank
private String clientName;
private List<String> redirectUris;
private List<String> grantTypes;
private List<String> responseTypes;
private String scope;
private String jwksUri;
private String jwks;
private String tokenEndpointAuthMethod;
private String softwareId;
private String softwareVersion;
private String softwareStatement;
private String registrationAccessToken;
private String registrationClientUri;
private Map<String, Object> metadata;
}
@Data
@Builder
public class ClientUpdateRequest {
private String clientName;
private List<String> redirectUris;
private List<String> grantTypes;
private List<String> responseTypes;
private String scope;
private String jwksUri;
private String jwks;
private String tokenEndpointAuthMethod;
private Map<String, Object> metadata;
}
4. DCR Controller Implementation
import org.springframework.web.bind.annotation.*;
import org.springframework.http.ResponseEntity;
import org.springframework.http.HttpStatus;
import org.springframework.validation.annotation.Validated;
import javax.validation.Valid;
import java.net.URI;
import java.time.Instant;
import java.util.*;
@RestController
@RequestMapping("/oauth/register")
@Validated
public class DynamicClientRegistrationController {
@Autowired
private ClientRegistrationService registrationService;
@Autowired
private SoftwareStatementValidator softwareValidator;
@Autowired
private ClientRepository clientRepository;
/**
* Register a new client (RFC 7591)
*/
@PostMapping
public ResponseEntity<ClientRegistrationResponse> registerClient(
@Valid @RequestBody ClientRegistrationRequest request,
@RequestHeader(value = "Authorization", required = false) String authorization) {
// Validate initial access token if provided
if (authorization != null) {
validateInitialAccessToken(authorization);
}
// Validate software statement if present
if (request.getSoftwareStatement() != null) {
softwareValidator.validate(request.getSoftwareStatement());
}
// Generate client credentials
String clientId = generateClientId();
String clientSecret = generateClientSecret();
String registrationToken = generateRegistrationToken();
// Create client entity
OAuthClient client = OAuthClient.builder()
.clientId(clientId)
.clientSecret(passwordEncoder.encode(clientSecret))
.clientName(request.getClientName())
.redirectUris(request.getRedirectUris())
.grantTypes(request.getGrantTypes() != null ?
request.getGrantTypes() : defaultGrantTypes())
.responseTypes(request.getResponseTypes() != null ?
request.getResponseTypes() : defaultResponseTypes())
.scopes(parseScopes(request.getScope()))
.jwksUri(request.getJwksUri())
.jwks(request.getJwks())
.tokenEndpointAuthMethod(request.getTokenEndpointAuthMethod() != null ?
request.getTokenEndpointAuthMethod() : "client_secret_basic")
.tokenEndpointAuthSigningAlg(request.getTokenEndpointAuthSigningAlg())
.requestObjectSigningAlg(request.getRequestObjectSigningAlg())
.softwareId(request.getSoftwareId())
.softwareVersion(request.getSoftwareVersion())
.softwareStatement(request.getSoftwareStatement())
.createdAt(Instant.now())
.clientIdIssuedAt(Instant.now())
.clientSecretExpiresAt(Instant.now().plusSeconds(365 * 86400)) // 1 year
.registrationAccessToken(passwordEncoder.encode(registrationToken))
.registrationClientUri("/oauth/register/" + clientId)
.additionalMetadata(request.getMetadata())
.build();
clientRepository.save(client);
// Build response
ClientRegistrationResponse response = ClientRegistrationResponse.builder()
.clientId(clientId)
.clientSecret(clientSecret)
.clientIdIssuedAt(client.getClientIdIssuedAt().getEpochSecond())
.clientSecretExpiresAt(client.getClientSecretExpiresAt().getEpochSecond())
.clientName(client.getClientName())
.redirectUris(client.getRedirectUris())
.grantTypes(client.getGrantTypes())
.responseTypes(client.getResponseTypes())
.scope(String.join(" ", client.getScopes()))
.jwksUri(client.getJwksUri())
.jwks(client.getJwks())
.tokenEndpointAuthMethod(client.getTokenEndpointAuthMethod())
.softwareId(client.getSoftwareId())
.softwareVersion(client.getSoftwareVersion())
.softwareStatement(client.getSoftwareStatement())
.registrationAccessToken(registrationToken)
.registrationClientUri(client.getRegistrationClientUri())
.metadata(client.getAdditionalMetadata())
.build();
return ResponseEntity
.created(URI.create("/oauth/register/" + clientId))
.body(response);
}
/**
* Get client information (RFC 7592)
*/
@GetMapping("/{clientId}")
public ResponseEntity<ClientRegistrationResponse> getClient(
@PathVariable String clientId,
@RequestHeader("Authorization") String authorization) {
OAuthClient client = clientRepository.findByClientId(clientId)
.orElseThrow(() -> new ClientNotFoundException(clientId));
validateRegistrationToken(authorization, client);
ClientRegistrationResponse response = mapToResponse(client);
return ResponseEntity.ok(response);
}
/**
* Update client information (RFC 7592)
*/
@PutMapping("/{clientId}")
public ResponseEntity<ClientRegistrationResponse> updateClient(
@PathVariable String clientId,
@Valid @RequestBody ClientUpdateRequest updateRequest,
@RequestHeader("Authorization") String authorization) {
OAuthClient client = clientRepository.findByClientId(clientId)
.orElseThrow(() -> new ClientNotFoundException(clientId));
validateRegistrationToken(authorization, client);
// Update fields
if (updateRequest.getClientName() != null) {
client.setClientName(updateRequest.getClientName());
}
if (updateRequest.getRedirectUris() != null) {
client.setRedirectUris(updateRequest.getRedirectUris());
}
if (updateRequest.getGrantTypes() != null) {
client.setGrantTypes(updateRequest.getGrantTypes());
}
if (updateRequest.getResponseTypes() != null) {
client.setResponseTypes(updateRequest.getResponseTypes());
}
if (updateRequest.getScope() != null) {
client.setScopes(parseScopes(updateRequest.getScope()));
}
if (updateRequest.getJwksUri() != null) {
client.setJwksUri(updateRequest.getJwksUri());
}
if (updateRequest.getJwks() != null) {
client.setJwks(updateRequest.getJwks());
}
if (updateRequest.getTokenEndpointAuthMethod() != null) {
client.setTokenEndpointAuthMethod(updateRequest.getTokenEndpointAuthMethod());
}
if (updateRequest.getMetadata() != null) {
client.setAdditionalMetadata(updateRequest.getMetadata());
}
clientRepository.save(client);
return ResponseEntity.ok(mapToResponse(client));
}
/**
* Delete client (RFC 7592)
*/
@DeleteMapping("/{clientId}")
public ResponseEntity<Void> deleteClient(
@PathVariable String clientId,
@RequestHeader("Authorization") String authorization) {
OAuthClient client = clientRepository.findByClientId(clientId)
.orElseThrow(() -> new ClientNotFoundException(clientId));
validateRegistrationToken(authorization, client);
clientRepository.delete(client);
return ResponseEntity.noContent().build();
}
private String generateClientId() {
byte[] randomBytes = new byte[32];
new SecureRandom().nextBytes(randomBytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes);
}
private String generateClientSecret() {
byte[] randomBytes = new byte[32];
new SecureRandom().nextBytes(randomBytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes);
}
private String generateRegistrationToken() {
byte[] randomBytes = new byte[32];
new SecureRandom().nextBytes(randomBytes);
return "reg_" + Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes);
}
}
5. Software Statement Validation Service
import com.nimbusds.jose.*;
import com.nimbusds.jose.crypto.RSASSAVerifier;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jwt.SignedJWT;
import com.nimbusds.jwt.JWTClaimsSet;
@Service
public class SoftwareStatementValidator {
@Autowired
private TrustedIssuerService trustedIssuerService;
@Autowired
private JWKCache jwkCache;
/**
* Validate a software statement JWT
*/
public SoftwareStatement validate(String softwareStatementJwt) {
try {
SignedJWT signedJWT = SignedJWT.parse(softwareStatementJwt);
// Get issuer
String issuer = signedJWT.getJWTClaimsSet().getIssuer();
// Fetch JWKS from trusted issuer
JWKSet jwkSet = jwkCache.getJwkSet(issuer);
// Find signing key
String keyId = signedJWT.getHeader().getKeyID();
RSAKey signingKey = (RSAKey) jwkSet.getKeyByKeyId(keyId);
if (signingKey == null) {
throw new InvalidSoftwareStatementException("No matching key found");
}
// Verify signature
JWSVerifier verifier = new RSASSAVerifier(signingKey.toRSAPublicKey());
if (!signedJWT.verify(verifier)) {
throw new InvalidSoftwareStatementException("Invalid signature");
}
// Validate claims
JWTClaimsSet claims = signedJWT.getJWTClaimsSet();
// Check expiration
if (claims.getExpirationTime() != null &&
claims.getExpirationTime().before(new Date())) {
throw new InvalidSoftwareStatementException("Software statement expired");
}
// Extract software information
return SoftwareStatement.builder()
.softwareId(claims.getStringClaim("software_id"))
.softwareVersion(claims.getStringClaim("software_version"))
.clientName(claims.getStringClaim("client_name"))
.redirectUris(claims.getStringListClaim("redirect_uris"))
.grantTypes(claims.getStringListClaim("grant_types"))
.scope(claims.getStringClaim("scope"))
.issuer(issuer)
.issuedAt(claims.getIssueTime())
.build();
} catch (Exception e) {
throw new InvalidSoftwareStatementException(
"Failed to validate software statement: " + e.getMessage(), e);
}
}
@Data
@Builder
public static class SoftwareStatement {
private String softwareId;
private String softwareVersion;
private String clientName;
private List<String> redirectUris;
private List<String> grantTypes;
private String scope;
private String issuer;
private Date issuedAt;
}
}
6. Client Authentication Service
@Service
public class ClientAuthenticationService {
@Autowired
private ClientRepository clientRepository;
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private JwtClientAssertionValidator jwtValidator;
/**
* Authenticate client for token endpoint
*/
public OAuthClient authenticateClient(String clientId, String clientSecret) {
OAuthClient client = clientRepository.findByClientId(clientId)
.orElseThrow(() -> new InvalidClientException("Unknown client"));
String authMethod = client.getTokenEndpointAuthMethod();
switch (authMethod) {
case "client_secret_basic":
case "client_secret_post":
return authenticateWithSecret(client, clientSecret);
case "client_secret_jwt":
return authenticateWithSecretJwt(client, clientSecret);
case "private_key_jwt":
return authenticateWithPrivateKeyJwt(client, clientSecret);
case "tls_client_auth":
return authenticateWithTls(client);
default:
throw new InvalidClientException("Unsupported auth method: " + authMethod);
}
}
/**
* Validate registration token for DCR endpoints
*/
public OAuthClient validateRegistrationToken(String authorization, String clientId) {
String token = extractToken(authorization);
OAuthClient client = clientRepository.findByClientId(clientId)
.orElseThrow(() -> new InvalidTokenException("Unknown client"));
if (!passwordEncoder.matches(token, client.getRegistrationAccessToken())) {
throw new InvalidTokenException("Invalid registration token");
}
return client;
}
}
7. Client Metadata Validation
@Component
public class ClientMetadataValidator {
@Autowired
private AllowedValuesConfig allowedValues;
public void validateMetadata(ClientRegistrationRequest request) {
List<String> violations = new ArrayList<>();
// Validate redirect URIs
if (request.getRedirectUris() != null) {
for (String uri : request.getRedirectUris()) {
if (!isValidRedirectUri(uri)) {
violations.add("Invalid redirect URI: " + uri);
}
}
}
// Validate grant types
if (request.getGrantTypes() != null) {
for (String grantType : request.getGrantTypes()) {
if (!allowedValues.getGrantTypes().contains(grantType)) {
violations.add("Unsupported grant type: " + grantType);
}
}
}
// Validate response types
if (request.getResponseTypes() != null) {
for (String responseType : request.getResponseTypes()) {
if (!allowedValues.getResponseTypes().contains(responseType)) {
violations.add("Unsupported response type: " + responseType);
}
}
}
// Validate token endpoint auth method
if (request.getTokenEndpointAuthMethod() != null) {
if (!allowedValues.getAuthMethods().contains(request.getTokenEndpointAuthMethod())) {
violations.add("Unsupported auth method: " + request.getTokenEndpointAuthMethod());
}
}
// Validate JWKS
if (request.getJwksUri() != null && request.getJwks() != null) {
violations.add("Cannot provide both jwks_uri and jwks");
}
// Validate PKCE requirements for public clients
if ("none".equals(request.getTokenEndpointAuthMethod())) {
if (request.getGrantTypes() != null &&
request.getGrantTypes().contains("authorization_code")) {
// Public clients must use PKCE
// This would be validated at authorization time
}
}
if (!violations.isEmpty()) {
throw new InvalidClientMetadataException(
"Invalid client metadata: " + String.join(", ", violations));
}
}
private boolean isValidRedirectUri(String uri) {
try {
URL url = new URI(uri).toURL();
String protocol = url.getProtocol();
if ("http".equals(protocol)) {
// Localhost exception for development
return "localhost".equals(url.getHost()) ||
"127.0.0.1".equals(url.getHost());
}
return "https".equals(protocol);
} catch (Exception e) {
return false;
}
}
}
8. DCR Client Example
@Component
public class DcrClientExample {
@Autowired
private RestTemplate restTemplate;
public OAuthClient registerClient(String registrationEndpoint,
ClientRegistrationRequest request,
String initialAccessToken) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
if (initialAccessToken != null) {
headers.setBearerAuth(initialAccessToken);
}
HttpEntity<ClientRegistrationRequest> entity =
new HttpEntity<>(request, headers);
ResponseEntity<ClientRegistrationResponse> response =
restTemplate.postForEntity(registrationEndpoint,
entity, ClientRegistrationResponse.class);
return mapToClient(response.getBody());
}
public ClientRegistrationResponse updateClient(String registrationUri,
ClientUpdateRequest update,
String registrationToken) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setBearerAuth(registrationToken);
HttpEntity<ClientUpdateRequest> entity =
new HttpEntity<>(update, headers);
ResponseEntity<ClientRegistrationResponse> response =
restTemplate.exchange(registrationUri, HttpMethod.PUT,
entity, ClientRegistrationResponse.class);
return response.getBody();
}
public void deleteClient(String registrationUri, String registrationToken) {
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(registrationToken);
HttpEntity<?> entity = new HttpEntity<>(headers);
restTemplate.exchange(registrationUri, HttpMethod.DELETE,
entity, Void.class);
}
}
9. Initial Access Token Management
@Service
public class InitialAccessTokenService {
@Autowired
private InitialTokenRepository tokenRepository;
public InitialAccessToken createToken(String clientId, Duration validity) {
String tokenValue = generateToken();
Instant expiresAt = Instant.now().plus(validity);
InitialAccessToken token = InitialAccessToken.builder()
.token(passwordEncoder.encode(tokenValue))
.clientId(clientId)
.createdAt(Instant.now())
.expiresAt(expiresAt)
.usageCount(0)
.maxUsage(100) // Limit uses
.build();
tokenRepository.save(token);
return token.withTokenValue(tokenValue);
}
public void validateToken(String tokenValue) {
InitialAccessToken token = tokenRepository.findByToken(tokenValue)
.orElseThrow(() -> new InvalidTokenException("Invalid token"));
if (token.getExpiresAt().isBefore(Instant.now())) {
throw new InvalidTokenException("Token expired");
}
if (token.getUsageCount() >= token.getMaxUsage()) {
throw new InvalidTokenException("Token usage limit exceeded");
}
// Increment usage count
token.setUsageCount(token.getUsageCount() + 1);
tokenRepository.save(token);
}
private String generateToken() {
byte[] randomBytes = new byte[32];
new SecureRandom().nextBytes(randomBytes);
return "iat_" + Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes);
}
@Data
@Builder
public static class InitialAccessToken {
private String token;
private String clientId;
private Instant createdAt;
private Instant expiresAt;
private int usageCount;
private int maxUsage;
public InitialAccessToken withTokenValue(String value) {
this.token = value;
return this;
}
}
}
10. DCR Configuration Properties
# application-dcr.yml oauth: dcr: enabled: true registration-endpoint: /oauth/register # Security settings require-initial-access-token: false allow-public-clients: true allow-localhost-redirects: true # Token settings client-secret-expiry-days: 365 registration-token-length: 32 client-id-length: 32 # Allowed values allowed-grant-types: - authorization_code - refresh_token - client_credentials - password allowed-response-types: - code - token - id_token allowed-auth-methods: - client_secret_basic - client_secret_post - client_secret_jwt - private_key_jwt - tls_client_auth - none # Software statement validation software-statement: required: false trusted-issuers: - https://trusted-issuer.example.com jwks-cache-ttl: 3600 # Initial access token initial-token: enabled: false validity-hours: 24 max-usage: 100
11. Exception Handling for DCR
@ControllerAdvice
public class DcrExceptionHandler {
@ExceptionHandler(InvalidClientMetadataException.class)
public ResponseEntity<DcrError> handleInvalidMetadata(InvalidClientMetadataException e) {
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(DcrError.builder()
.error("invalid_client_metadata")
.errorDescription(e.getMessage())
.build());
}
@ExceptionHandler(InvalidSoftwareStatementException.class)
public ResponseEntity<DcrError> handleInvalidSoftwareStatement(InvalidSoftwareStatementException e) {
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(DcrError.builder()
.error("invalid_software_statement")
.errorDescription(e.getMessage())
.build());
}
@ExceptionHandler(InvalidTokenException.class)
public ResponseEntity<DcrError> handleInvalidToken(InvalidTokenException e) {
return ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body(DcrError.builder()
.error("invalid_token")
.errorDescription(e.getMessage())
.build());
}
@ExceptionHandler(ClientNotFoundException.class)
public ResponseEntity<DcrError> handleClientNotFound(ClientNotFoundException e) {
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(DcrError.builder()
.error("client_not_found")
.errorDescription(e.getMessage())
.build());
}
@Data
@Builder
public static class DcrError {
private String error;
private String errorDescription;
}
}
12. Database Schema
-- OAuth clients table CREATE TABLE oauth_clients ( id BIGINT AUTO_INCREMENT PRIMARY KEY, client_id VARCHAR(255) NOT NULL UNIQUE, client_secret VARCHAR(255) NOT NULL, client_name VARCHAR(255) NOT NULL, jwks TEXT, jwks_uri VARCHAR(512), token_endpoint_auth_method VARCHAR(50), token_endpoint_auth_signing_alg VARCHAR(20), request_object_signing_alg VARCHAR(20), software_id VARCHAR(255), software_version VARCHAR(50), software_statement TEXT, created_at TIMESTAMP NOT NULL, client_id_issued_at TIMESTAMP NOT NULL, client_secret_expires_at TIMESTAMP, registration_access_token VARCHAR(255), registration_client_uri VARCHAR(512), additional_metadata TEXT, INDEX idx_client_id (client_id) ); -- Client redirect URIs CREATE TABLE client_redirect_uris ( client_id BIGINT NOT NULL, redirect_uri VARCHAR(512) NOT NULL, FOREIGN KEY (client_id) REFERENCES oauth_clients(id), INDEX idx_client_id (client_id) ); -- Client grant types CREATE TABLE client_grant_types ( client_id BIGINT NOT NULL, grant_type VARCHAR(50) NOT NULL, FOREIGN KEY (client_id) REFERENCES oauth_clients(id), INDEX idx_client_id (client_id) ); -- Client response types CREATE TABLE client_response_types ( client_id BIGINT NOT NULL, response_type VARCHAR(50) NOT NULL, FOREIGN KEY (client_id) REFERENCES oauth_clients(id), INDEX idx_client_id (client_id) ); -- Client scopes CREATE TABLE client_scopes ( client_id BIGINT NOT NULL, scope VARCHAR(100) NOT NULL, FOREIGN KEY (client_id) REFERENCES oauth_clients(id), INDEX idx_client_id (client_id) ); -- Initial access tokens CREATE TABLE initial_access_tokens ( id BIGINT AUTO_INCREMENT PRIMARY KEY, token VARCHAR(255) NOT NULL UNIQUE, client_id VARCHAR(255), created_at TIMESTAMP NOT NULL, expires_at TIMESTAMP NOT NULL, usage_count INT DEFAULT 0, max_usage INT, INDEX idx_token (token) );
Testing DCR Implementation
@SpringBootTest
@AutoConfigureMockMvc
class DcrIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Test
void testClientRegistration() throws Exception {
// Create registration request
ClientRegistrationRequest request = ClientRegistrationRequest.builder()
.clientName("Test Client")
.redirectUris(List.of("https://client.example.com/callback"))
.grantTypes(List.of("authorization_code", "refresh_token"))
.responseTypes(List.of("code"))
.scope("openid profile email")
.tokenEndpointAuthMethod("client_secret_basic")
.build();
String requestJson = objectMapper.writeValueAsString(request);
// Register client
MvcResult result = mockMvc.perform(post("/oauth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(requestJson))
.andExpect(status().isCreated())
.andReturn();
ClientRegistrationResponse response = objectMapper.readValue(
result.getResponse().getContentAsString(),
ClientRegistrationResponse.class);
assertNotNull(response.getClientId());
assertNotNull(response.getClientSecret());
assertEquals("Test Client", response.getClientName());
assertEquals(1, response.getRedirectUris().size());
// Retrieve client
mockMvc.perform(get("/oauth/register/{clientId}", response.getClientId())
.header("Authorization", "Bearer " + response.getRegistrationAccessToken()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.clientName").value("Test Client"));
// Update client
ClientUpdateRequest update = ClientUpdateRequest.builder()
.clientName("Updated Client")
.build();
String updateJson = objectMapper.writeValueAsString(update);
mockMvc.perform(put("/oauth/register/{clientId}", response.getClientId())
.header("Authorization", "Bearer " + response.getRegistrationAccessToken())
.contentType(MediaType.APPLICATION_JSON)
.content(updateJson))
.andExpect(status().isOk())
.andExpect(jsonPath("$.clientName").value("Updated Client"));
// Delete client
mockMvc.perform(delete("/oauth/register/{clientId}", response.getClientId())
.header("Authorization", "Bearer " + response.getRegistrationAccessToken()))
.andExpect(status().isNoContent());
}
}
Best Practices
- Secure Registration Endpoints: Require HTTPS and initial access tokens
- Validate All Metadata: Check redirect URIs, grant types, and scopes
- Software Statement Validation: Verify signatures and trust chains
- Rate Limiting: Prevent abuse through registration flooding
- Audit Logging: Track all registration, update, and deletion events
- Secret Storage: Encrypt client secrets at rest
- Registration Tokens: Use for subsequent client management
- Client ID Format: Use secure random values, not sequential IDs
- Metadata Evolution: Support versioning of client metadata
- Compliance: Follow RFC 7591 and RFC 7592 specifications
Conclusion
Dynamic Client Registration provides Java applications with a standardized, secure mechanism for programmatic client onboarding in OAuth 2.0 and OpenID Connect ecosystems. By implementing DCR, authorization servers can:
- Automate client management without manual intervention
- Support third-party integration with proper security controls
- Meet regulatory requirements for open banking and financial APIs
- Enable self-service portals for developer onboarding
- Maintain audit trails of all client lifecycle events
The implementation presented here covers the core requirements of RFC 7591 and RFC 7592, including registration, retrieval, update, and deletion of clients. With proper security measures—software statement validation, initial access tokens, and metadata validation—DCR becomes a secure foundation for scalable OAuth 2.0 deployments.
For organizations building OAuth 2.0 authorization servers in Java, Dynamic Client Registration is an essential feature that enables ecosystem growth while maintaining security and compliance. Whether for open banking, enterprise integration, or public API platforms, DCR provides the automation and flexibility needed in modern identity management.
Java Programming Basics – Variables, Loops, Methods, Classes, Files & Exception Handling (Related to Java Programming)
Variables and Data Types in Java:
This topic explains how variables store data in Java and how data types define the kind of values a variable can hold, such as numbers, characters, or text. Java includes primitive types like int, double, and boolean, which are essential for storing and managing data in programs. (GeeksforGeeks)
Read more: https://macronepal.com/blog/variables-and-data-types-in-java/
Basic Input and Output in Java:
This lesson covers how Java programs receive input from users and display output using tools like Scanner for input and System.out.println() for output. These operations allow interaction between the program and the user.
Read more: https://macronepal.com/blog/basic-input-output-in-java/
Arithmetic Operations in Java:
This guide explains mathematical operations such as addition, subtraction, multiplication, and division using operators like +, -, *, and /. These operations are used to perform calculations in Java programs.
Read more: https://macronepal.com/blog/arithmetic-operations-in-java/
If-Else Statement in Java:
The if-else statement allows programs to make decisions based on conditions. It helps control program flow by executing different blocks of code depending on whether a condition is true or false.
Read more: https://macronepal.com/blog/if-else-statement-in-java/
For Loop in Java:
A for loop is used to repeat a block of code a specific number of times. It is commonly used when the number of repetitions is known in advance.
Read more: https://macronepal.com/blog/for-loop-in-java/
Method Overloading in Java:
Method overloading allows multiple methods to have the same name but different parameters. It improves code readability and flexibility by allowing similar tasks to be handled using one method name.
Read more: https://macronepal.com/blog/method-overloading-in-java-a-complete-guide/
Basic Inheritance in Java:
Inheritance is an object-oriented concept that allows one class to inherit properties and methods from another class. It promotes code reuse and helps build hierarchical class structures.
Read more: https://macronepal.com/blog/basic-inheritance-in-java-a-complete-guide/
File Writing in Java:
This topic explains how to create and write data into files using Java. File writing is commonly used to store program data permanently.
Read more: https://macronepal.com/blog/file-writing-in-java-a-complete-guide/
File Reading in Java:
File reading allows Java programs to read stored data from files. It is useful for retrieving saved information and processing it inside applications.
Read more: https://macronepal.com/blog/file-reading-in-java-a-complete-guide/
Exception Handling in Java:
Exception handling helps manage runtime errors using tools like try, catch, and finally. It prevents programs from crashing and allows safe error handling.
Read more: https://macronepal.com/blog/exception-handling-in-java-a-complete-guide/
Constructors in Java:
Constructors are special methods used to initialize objects when they are created. They help assign initial values to object variables automatically.
Read more: https://macronepal.com/blog/constructors-in-java/
Classes and Objects in Java:
Classes are blueprints used to create objects, while objects are instances of classes. These concepts form the foundation of object-oriented programming in Java.
Read more: https://macronepal.com/blog/classes-and-object-in-java/
Methods in Java:
Methods are blocks of code that perform specific tasks. They help organize programs into smaller reusable sections and improve code readability.
Read more: https://macronepal.com/blog/methods-in-java/
Arrays in Java:
Arrays store multiple values of the same type in a single variable. They are useful for handling lists of data such as numbers or names.
Read more: https://macronepal.com/blog/arrays-in-java/
While Loop in Java:
A while loop repeats a block of code as long as a given condition remains true. It is useful when the number of repetitions is not known beforehand.
Read more: https://macronepal.com/blog/while-loop-in-java/