Managing clients and scopes
ADMIN-only APIs for OAuth clients and a scope registry, with roles carried in the access token.
You will build
a third filter chain for bearer-token management APIs, client CRUD with one-time secrets, and a validated scope registry
You will understand
how roles travel through a JWT and back into authorities, public versus confidential clients, and how to issue a secret that is shown exactly once
- one migration, two APIs
Hardcoding every OAuth client is not a management strategy.
We now make the authorization server protect and manage itself.
The architecture becomes:
flowchart LR
Admin[Authenticated ADMIN]
JWT[Access Token with roles]
API[Management APIs]
RC[(Registered Clients)]
SC[(OAuth Scopes)]
Admin --> JWT --> API
API --> RC
API --> SC
Put roles into access-token JWTs
We already map database roles to Spring authorities:
ADMIN → ROLE_ADMIN
USER → ROLE_USER
Add an OAuth2TokenCustomizer<JwtEncodingContext> that only modifies access tokens:
if (OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType())) {
Set<String> roles = context.getPrincipal()
.getAuthorities()
.stream()
.map(GrantedAuthority::getAuthority)
.filter(a -> a.startsWith("ROLE_"))
.map(a -> a.substring("ROLE_".length()))
.collect(Collectors.toSet());
context.getClaims().claim("roles", roles);
}
A resulting token can contain:
{
"roles": ["USER", "ADMIN"]
}
Convert JWT roles back into authorities
When the same application acts as a resource server for its management APIs, the inbound JWT must become Spring authorities again.
Keep normal scope conversion (SCOPE_read) and add role conversion:
JWT scope claim → SCOPE_*
JWT roles claim → ROLE_*
Use JwtGrantedAuthoritiesConverter plus DelegatingJwtGrantedAuthoritiesConverter and a JwtAuthenticationConverter.
Add a dedicated management filter chain
The chains now look like:
@Order(1) OAuth/OIDC endpoints
@Order(2) Management APIs — Bearer JWT / ADMIN
@Order(3) Login/registration/default application
Matcher:
.securityMatcher(
"/api/v1/clients",
"/api/v1/clients/**",
"/api/v1/scopes",
"/api/v1/scopes/**",
"/api/v1/users",
"/api/v1/users/**"
)
Authorization:
.authorizeHttpRequests(authorize ->
authorize.anyRequest().hasRole("ADMIN")
)
Resource server:
.oauth2ResourceServer(oauth2 ->
oauth2.jwt(jwt ->
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter)
)
)
CSRF can be disabled for this stateless bearer-token API chain without disabling it across browser login flows.
Client types
Define:
public enum OAuthClientType {
PUBLIC,
CONFIDENTIAL
}
Confidential client
client authentication = client_secret_basic
authorization grants = authorization_code + refresh_token
secret = generated securely
PKCE = required
Public client
client authentication = none
authorization grants = authorization_code
secret = none
PKCE = required
Generate client secrets securely
Do not generate secrets from predictable UUID formatting alone if the secret is meant to be a high-entropy credential.
Use SecureRandom:
byte[] bytes = new byte[32];
secureRandom.nextBytes(bytes);
String rawSecret = Base64.getUrlEncoder()
.withoutPadding()
.encodeToString(bytes);
Store only the encoded value:
passwordEncoder.encode(rawSecret)
Return the raw value only on creation or rotation.
That gives a familiar credential lifecycle:
flowchart LR
Gen[Generate random secret] --> Raw[Raw secret]
Raw --> Caller[Return once]
Raw --> Hash[PasswordEncoder]
Hash --> DB[(Database)]
API shape
POST /api/v1/clients
GET /api/v1/clients
GET /api/v1/clients/{clientId}
PUT /api/v1/clients/{clientId}
DELETE /api/v1/clients/{clientId}
POST /api/v1/clients/{clientId}/secret/rotate
Response DTOs must not return the stored secret hash.
Creation can return:
{
"client": {
"clientId": "...",
"clientName": "Orders UI",
"clientType": "CONFIDENTIAL"
},
"clientSecret": "raw-secret-visible-once"
}
Normal reads return only the safe client metadata.
Why a small JDBC management repository?
RegisteredClientRepository intentionally focuses on save/find semantics. For list/delete/low-level secret update operations, a small JdbcTemplate repository can perform:
find all registered-client IDs
delete consent
delete authorization
delete client
update client secret
When deleting a client, remove dependent consent and authorization state transactionally before deleting the registered client.
Create a custom OAuth scope registry
Add a Flyway table in V10__create_oauth_scopes.sql:
CREATE TABLE oauth_scopes (
id UUID NOT NULL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
description VARCHAR(255),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
Seed custom scopes in the same migration:
INSERT INTO oauth_scopes (id, name, description, enabled)
VALUES
('00000000-0000-0000-0000-000000000101', 'read', 'Read access to protected resources', TRUE),
('00000000-0000-0000-0000-000000000102', 'write', 'Write access to protected resources', TRUE)
ON CONFLICT (name) DO NOTHING;
Keep standard OIDC scopes such as openid, profile, and email as built-in protocol scopes rather than database-managed custom scopes.
Validate requested client scopes
The service should:
- trim/deduplicate the requested set;
- separate standard OIDC scopes from custom scopes;
- query enabled custom scopes;
- reject unknown or disabled values.
Example failure:
{
"message": "Unknown or disabled OAuth scopes: unknown-scope"
}
This prevents arbitrary strings from silently becoming client permissions.
Scope management API
GET /api/v1/scopes
POST /api/v1/scopes
PUT /api/v1/scopes/{name}
Use enable/disable rather than physical deletion as the basic management model.
A disabled scope should no longer be assignable to new/updated clients.
Important nuance: disabling a registry entry does not magically remove a scope from already-issued JWTs. Existing tokens remain governed by their normal lifetime/revocation policy.
Obtain an ADMIN access token
Use the complete PKCE flow from Part 3 and log in as the development user, who now has both USER and ADMIN roles.
Verify:
curl -i \
-H "Authorization: Bearer $ACCESS_TOKEN" \
http://localhost:9000/api/v1/clients
Expected:
200 OK
A token containing only ROLE_USER should receive 403 Forbidden.
Create a custom scope
curl -i \
-X POST http://localhost:9000/api/v1/scopes \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "orders.read",
"description": "Read access to orders"
}'
Then create a client using it.
Verification checklist
[ ] access JWT contains roles
[ ] ROLE_ADMIN is reconstructed from JWT
[ ] non-admin gets 403
[ ] confidential client secret is returned only on create/rotate
[ ] public client has no secret
[ ] client CRUD works
[ ] scope registry validates custom scopes
[ ] disabled scope is rejected for new/update operations
The code
This part corresponds to these commits in the repository:
17fa01cfeat: add admin authorization for management APIs3928760feat: complete OAuth client management lifecycleb0b214ffeat: add OAuth scope registry and validation0e0b2bffeat: add OAuth scope management API
In the repository the scope registry was committed after the Part 8 test and signing-key commits, so checking out the last one also brings in that later work:
git checkout 0e0b2bf
References
- Spring Security Resource Server JWT: https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/jwt.html
- Spring Authorization Server core model: https://docs.spring.io/spring-authorization-server/reference/core-model-components.html
- OAuth 2.1 draft: https://datatracker.ietf.org/doc/draft-ietf-oauth-v2-1/
Next: make the system testable against real PostgreSQL and make RSA signing identity survive restarts.