Managing clients and scopes

ADMIN-only APIs for OAuth clients and a scope registry, with roles carried in the access token.

  • Part 7
  • advanced
  • about 90 minutes

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:

  1. trim/deduplicate the requested set;
  2. separate standard OIDC scopes from custom scopes;
  3. query enabled custom scopes;
  4. 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:

  • 17fa01c feat: add admin authorization for management APIs
  • 3928760 feat: complete OAuth client management lifecycle
  • b0b214f feat: add OAuth scope registry and validation
  • 0e0b2bf feat: 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

Next: make the system testable against real PostgreSQL and make RSA signing identity survive restarts.