This tutorial walks through a three-module OAuth2 implementation using Spring Boot:
an Authorization Server that issues JWT tokens, a Resource Server that validates them, and a Client
that drives the Authorization Code flow. All three run as independent Spring Boot applications
on distinct ports (8082, 8081, 8080) using hostname aliases
to simulate separate domains.
addAppProfile, updateAppProfile,
deleteAppProfile, openid) are declared on the registered client and consented to by
the user. When the resource server ingests the JWT, each scope is converted to a GrantedAuthority
prefixed SCOPE_, which is why authorization rules use hasAuthority("SCOPE_addAppProfile")
rather than hasRole()./oauth2/jwks at startup and uses it to verify JWT signatures
locally on every request, with no network round-trip per call.http://mtitekauthserver:8082http://mtitekauthresource:8081http://mtitekauthclient:8080
Each service uses a distinct hostname alias to avoid cookie/session collisions and to exercise real
cross-origin redirect behaviour. Add the following to C:\Windows\System32\drivers\etc\hosts
(or /etc/hosts on Linux/macOS):
127.0.0.1 mtitekauthclient 127.0.0.1 mtitekauthserver 127.0.0.1 mtitekauthresource
spring-security-oauth2-authorization-server and
spring-boot-starter-security-oauth2-authorization-server — both are declared.
The starter pulls in autoconfiguration; the core artifact is the Spring Authorization Server library itself.spring-boot-starter-data-jpa + H2 — used exclusively for the AppUser entity
that backs UserDetailsService. Registered clients are kept in memory, not in the database.
Two SecurityFilterChain beans are required: one dedicated to authorization server endpoints,
the other for everything else (login form, OIDC discovery URLs). Ordering is important.
authorizationServerConfigurer.getEndpointsMatcher() scopes the first chain exclusively
to /oauth2/authorize, /oauth2/token, /oauth2/jwks,
/oauth2/revoke, /oauth2/introspect and OIDC endpoints. Requests not
matching those paths fall through to the second chain.configurer.oidc(Customizer.withDefaults()) enables the OIDC layer explicitly,
which activates the /userinfo endpoint and the OIDC discovery document at
/.well-known/openid-configuration. Without this, openid scope in the token
request is ignored.defaultAuthenticationEntryPointFor on the first chain is necessary because
authorization server endpoints themselves need to redirect HTML browsers to the login page on
401, while API clients (exchanging a code for a token) should receive a 401
JSON response — the MediaTypeRequestMatcher discriminates between the two./oauth2/jwks are explicitly permitAll() in the
second chain so the client can fetch provider metadata and the resource server can retrieve the public key
without authentication.@Bean
@Order(Ordered.HIGHEST_PRECEDENCE)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfigurer authorizationServerConfigurer = new OAuth2AuthorizationServerConfigurer();
http.securityMatcher(authorizationServerConfigurer.getEndpointsMatcher())
.with(authorizationServerConfigurer, configurer -> configurer.oidc(Customizer.withDefaults()))
.authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated())
.exceptionHandling(exceptions -> exceptions.defaultAuthenticationEntryPointFor(new LoginUrlAuthenticationEntryPoint("/login"), new MediaTypeRequestMatcher(MediaType.TEXT_HTML)));
return http.formLogin(Customizer.withDefaults()).build();
}
@Bean
@Order(2)
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/.well-known/openid-configuration", "/.well-known/oauth-authorization-server", "/oauth2/jwks").permitAll()
.anyRequest().authenticated())
.formLogin(Customizer.withDefaults());
return http.build();
}
clientSecret must be BCrypt-encoded here; the authorization server compares the
incoming Basic auth credential against this stored hash. Passing the plain-text value will cause
all token requests to fail with a 401.requireAuthorizationConsent(true) forces an explicit consent screen on every
authorization request — the user must check each scope individually. Setting it to false
skips that screen and auto-approves all requested scopes.requireProofKey(false) disables PKCE. The redirect URI is pre-registered and the
client secret is used for authentication, so PKCE is not strictly required here, but should be
enabled for public clients.redirectUri path segment mtitek-registration-id must match exactly
the registrationId key defined in the client's application.yml. Any mismatch
produces an invalid_grant error from the token endpoint.InMemoryRegisteredClientRepository is used — client registration survives only as long
as the JVM is up. A production setup should use JdbcRegisteredClientRepository.RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("mtitek-client-id")
.clientSecret(passwordEncoder.encode("mtitek-client-secret"))
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.redirectUri("http://mtitekauthclient:8080/login/oauth2/code/mtitek-registration-id")
.scope("addAppProfile").scope("updateAppProfile").scope("deleteAppProfile")
.scope(OidcScopes.OPENID)
.clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).requireProofKey(false).build())
.build();
return new InMemoryRegisteredClientRepository(registeredClient);
RSAKey.load(keyStore, alias, keyPassword)..jks files via a secrets
manager (AWS Secrets Manager, HashiCorp Vault) or environment-specific external configuration.Generate the keystore once:
keytool -genkeypair \ -alias mtitek-auth-server \ -keyalg RSA \ -keysize 2048 \ -storetype JKS \ -keystore mtitek-auth-server.jks \ -validity 3650 \ -storepass mtitek-auth-server-keystore-pwd \ -keypass mtitek-auth-server-key-pwd
Place mtitek-auth-server.jks in src/main/resources/ and configure via
application.yml:
app:
security:
keystore:
path: mtitek-auth-server.jks
password: mtitek-auth-server-keystore-pwd
alias: mtitek-auth-server
keyPassword: mtitek-auth-server-key-pwd
iss claim. The client's
issuer-uri property and the resource server's JWK set URI must resolve against this
exact value. A trailing slash or protocol mismatch will cause token validation to fail.@Bean
public AuthorizationServerSettings authorizationServerSettings() {
return AuthorizationServerSettings.builder().issuer("http://mtitekauthserver:8082").build();
}
AppUser implements UserDetails directly, so the lambda body is a complete
implementation. The entity stores username, password (BCrypt), and role.
The getAuthorities() method is @Transient — JPA does not persist it.@NoArgsConstructor(access = AccessLevel.PRIVATE, force = true) satisfies the JPA
requirement for a no-arg constructor while preventing accidental direct instantiation, alongside
@RequiredArgsConstructor for the three final fields.@Bean
UserDetailsService userDetailsService(AppUserRepository userRepo) {
return username -> userRepo.findByUsername(username);
}
spring-boot-starter-oauth2-resource-server is required. No authorization server
library is needed.spring.security.oauth2.resourceserver.jwt.jwk-set-uri=http://mtitekauthserver:8082/oauth2/jwks
/api/** via securityMatcher. Any
request not matching that prefix is processed by the second (web) chain at @Order(2),
which still uses form login and role-based access — demonstrating that both authentication
mechanisms coexist in the same application.SCOPE_ prefix by Spring's
JwtGrantedAuthoritiesConverter. Authorization rules must use hasAuthority("SCOPE_xxx")
— using hasRole("xxx") or hasAuthority("xxx") without the prefix will silently
deny all requests.GET and any unmatched verb on /api/appProfiles/** is permitAll(),
meaning listing and fetching profiles requires no JWT. Only write operations are scope-protected.SessionCreationPolicy.STATELESS ensures no JSESSIONID cookie is issued
for API clients, and csrf().disable() avoids CSRF token requirements for REST.@Bean
@Order(1)
public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
return http.securityMatcher("/api/**")
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.POST, "/api/appProfiles/**").hasAuthority("SCOPE_addAppProfile")
.requestMatchers(HttpMethod.PUT, "/api/appProfiles/**").hasAuthority("SCOPE_updateAppProfile")
.requestMatchers(HttpMethod.PATCH, "/api/appProfiles/**").hasAuthority("SCOPE_updateAppProfile")
.requestMatchers(HttpMethod.DELETE, "/api/appProfiles/**").hasAuthority("SCOPE_deleteAppProfile")
.anyRequest().permitAll())
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
.httpBasic(Customizer.withDefaults())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(csrf -> csrf.disable())
.build();
}
ConcurrentHashMap — data is lost on restart.
@CrossOrigin is scoped to http://mtitekauthclient:8080 only, which is the
expected client origin.@PostMapping returns the previous value for the key from
map.put(), not the newly stored object. On first insert this is null, so the
201 Created body will be empty — intentional for this demo, but worth noting.@RestController
@RequestMapping(path = "/api/appProfiles", produces = "application/json")
@CrossOrigin(origins = "http://mtitekauthclient:8080")
public class AppProfileController {
Map<String, AppProfile> appProfiles = new ConcurrentHashMap<>();
...
}
mtitek-registration-id must match the path segment in
redirect-uri (via {registrationId}) and the value registered on the
authorization server's redirectUri.issuer-uri is set, Spring auto-discovers all other URIs from
/.well-known/openid-configuration. The explicit authorization-uri,
token-uri, etc. are redundant here (the code comments acknowledge this) but make
dependencies visible.user-name-attribute: sub maps the sub claim from the OIDC ID token
to the principal name. Without this, Spring defaults to a provider-specific attribute which may
not exist for custom authorization servers.spring:
security:
oauth2:
client:
registration:
mtitek-registration-id:
provider: mtitek
client-id: mtitek-client-id
client-secret: mtitek-client-secret
authorization-grant-type: authorization_code
redirect-uri: "http://mtitekauthclient:8080/login/oauth2/code/{registrationId}"
scope: openid,addAppProfile,updateAppProfile,deleteAppProfile
provider:
mtitek:
issuer-uri: http://mtitekauthserver:8082
authorization-uri: http://mtitekauthserver:8082/oauth2/authorize
token-uri: http://mtitekauthserver:8082/oauth2/token
jwk-set-uri: http://mtitekauthserver:8082/oauth2/jwks
user-info-uri: http://mtitekauthserver:8082/userinfo
user-name-attribute: sub
loginPage("/oauth2/authorization/mtitek-registration-id") redirects unauthenticated
users directly to the authorization server's login page rather than showing a Spring-generated
login selection page.HttpSessionOAuth2AuthorizationRequestRepository stores the pending authorization
request (including the state parameter) in the HTTP session before the redirect. On
callback, Spring retrieves it to validate the state, preventing CSRF on the redirect.
Losing the session between the redirect and the callback (e.g. server restart) breaks the flow.authenticationEntryPoint is set explicitly to the same login URL — this handles
the case where a user navigates directly to a protected page; without it, Spring would send a
403 instead of redirecting to login.oauth2Client(Customizer.withDefaults()) registers the
OAuth2AuthorizedClientManager infrastructure needed to manage and refresh tokens.
Without this, OAuth2AuthorizedClientService autowiring still works but programmatic
token refresh is not handled automatically.@Bean
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(authorizeRequests -> authorizeRequests.anyRequest().authenticated())
.oauth2Login(oauth2Login -> oauth2Login
.loginPage("/oauth2/authorization/mtitek-registration-id")
.authorizationEndpoint(authorization -> authorization.authorizationRequestRepository(new HttpSessionOAuth2AuthorizationRequestRepository())))
.exceptionHandling(ex -> ex.authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/oauth2/authorization/mtitek-registration-id")))
.oauth2Client(Customizer.withDefaults());
return http.build();
}
RestTemplate at construction time,
but resolveAccessToken() is called lazily on each HTTP request. This is
important: if the token were captured at construction, it would be stale for the lifetime of the bean.
Since AppProfileRestService is a singleton @Component, fresh resolution
per-request via SecurityContextHolder is the correct pattern here.OAuth2AuthorizedClientService.loadAuthorizedClient(registrationId, principalName)
loads from the in-memory store. After a server restart, this store is wiped, so the lookup returns
null even if the user's session cookie is still valid — the null guard prevents an NPE
but the subsequent REST call will fail with a 401. The commented-out alternatives in
SecurityConfig show two approaches: throw OAuth2AuthorizationException
to trigger re-auth, or invalidate the session and redirect explicitly."exp": iat + 300).
After expiry, the resource server will return 401. The client does not implement
automatic refresh here — a production implementation should use
OAuth2AuthorizedClientManager with DefaultOAuth2AuthorizedClientManager
which handles token refresh transparently.@Component
@Slf4j
public class AppProfileRestService implements AppProfileService {
private final OAuth2AuthorizedClientService oauth2AuthorizedClientService;
private final RestTemplate restTemplate;
public AppProfileRestService(OAuth2AuthorizedClientService oauth2AuthorizedClientService) {
this.oauth2AuthorizedClientService = oauth2AuthorizedClientService;
this.restTemplate = new RestTemplate();
this.restTemplate.getInterceptors().add(getBearerTokenInterceptor());
}
private ClientHttpRequestInterceptor getBearerTokenInterceptor() {
return (request, body, execution) -> {
String accessToken = resolveAccessToken();
if (accessToken != null) {
request.getHeaders().add("Authorization", "Bearer " + accessToken);
}
return execution.execute(request, body);
};
}
public String resolveAccessToken() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication instanceof OAuth2AuthenticationToken oauth2AuthenticationToken) {
String registrationId = oauth2AuthenticationToken.getAuthorizedClientRegistrationId();
if (registrationId.equals("mtitek-registration-id")) {
OAuth2AuthorizedClient client = oauth2AuthorizedClientService.loadAuthorizedClient(registrationId, oauth2AuthenticationToken.getName());
if (client != null && client.getAccessToken() != null) {
return client.getAccessToken().getTokenValue();
}
}
}
return null;
}
}
@ModelAttribute without a @RequestMapping runs before every handler
method in this controller, meaning every request to /appProfiles/** fetches all profiles
from the resource server.@ExceptionHandler(HttpClientErrorException.class) catches 4xx responses
from RestTemplate. When the resource server returns 403 (missing scope),
the error form is rendered instead of an unhandled exception page.@Controller
@RequestMapping("/appProfiles")
@RequiredArgsConstructor
public class AppProfilesController {
private final AppProfileService appProfileService;
@ModelAttribute
public void addAppProfilesToModel(Model model) {
List<AppProfile> appProfiles = appProfileService.getAppProfiles();
model.addAttribute("appProfiles", appProfiles);
}
@ExceptionHandler(HttpClientErrorException.class)
public String handleHttpClientErrorException(HttpClientErrorException ex, Model model) {
model.addAttribute("errorMessage", ex.getMessage());
model.addAttribute("errorStatusCode", ex.getStatusCode());
return "appProfileErrorForm";
}
}
user1/pwd1 on the authorization server's form login,
then approves each scope on the consent screen.GET http://mtitekauthserver:8082/oauth2/authorize ?response_type=code &client_id=mtitek-client-id &scope=openid+addAppProfile+updateAppProfile+deleteAppProfile &redirect_uri=http://mtitekauthclient:8080/login/oauth2/code/mtitek-registration-id
-u), matching
CLIENT_SECRET_BASIC on the registered client.access_token (JWT, 5-minute TTL), refresh_token,
and id_token (OIDC). The id_token is present because openid
scope was requested.scope array claim — verifiable at
https://jwt.io using the public key from http://mtitekauthserver:8082/oauth2/jwks.curl -iv mtitekauthserver:8082/oauth2/token \
-H "Content-type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "redirect_uri=http://mtitekauthclient:8080/login/oauth2/code/mtitek-registration-id" \
-d "code=<authorization_code>" \
-u mtitek-client-id:mtitek-client-secret
iss claim against the configured JWK URI origin, verifies exp,
and then extracts scopes as authorities before evaluating the authorization rule.curl http://mtitekauthresource:8081/api/appProfiles \
-H "Authorization: Bearer <access_token>"
REFRESH_TOKEN grant type must be explicitly registered on the
RegisteredClient — it is not automatically included with AUTHORIZATION_CODE.
Omitting it means the authorization server will reject refresh token requests with
unsupported_grant_type.curl mtitekauthserver:8082/oauth2/token \
-H "Content-type: application/x-www-form-urlencoded" \
-d "grant_type=refresh_token&refresh_token=<refresh_token>" \
-u mtitek-client-id:mtitek-client-secret
http://mtitekauthserver:8082/.well-known/openid-configuration — OIDC discovery
document; lists all supported endpoints, grant types, and signing algorithms. The client
issuer-uri setting causes Spring to fetch this on startup to auto-populate provider
configuration.http://mtitekauthserver:8082/.well-known/oauth-authorization-server — OAuth2
Authorization Server Metadata (RFC 8414); equivalent content to the OIDC discovery document.http://mtitekauthserver:8082/oauth2/jwks — the JWK Set; exposes the RSA public key
(as a JSON object with kid, kty, n, e) that the
resource server and clients use to verify JWT signatures.