Appearance
Spring Security Part 8: OAuth2 Login
A quick recap of the authorization code grant (previous part): the client registers with the authorization server and gets a client id and secret; the user is redirected to the authorization server, logs in and consents; the client receives an authorization code, exchanges it (with its secret) for tokens, and uses them.
Here our Spring Boot app is the client, and users log in with GitLab or Auth0 instead of a password stored in our database.
OAuth2 vs OIDC
| OAuth2 | OIDC | |
|---|---|---|
| Full form | Open Authorization | OpenID Connect |
| Purpose | Authorization: give a third-party app secure access to the user's protected data (e.g. an app showing your Google Calendar) | Authentication: a layer on top of OAuth2 that lets the app verify who the user is |
| Tokens | Access token (opaque or JWT), used by the app to call the resource server's APIs. Opaque tokens can be interpreted only by the authorization server. | ID token (always a JWT) plus an access token. The ID token is meant for the client app itself and carries minimal user information for authentication. The access token can still call APIs for more data. |
| Scope | read/write, profile, email, ... | openid (required): asks for an ID token. openid profile adds profile information to it. |
For "log in with GitLab", we need to know who the user is: OIDC, with scope openid.
Step 1: register the app
GitLab: Edit profile → Applications → Add new application: a name, the redirect URI (http://localhost:8080/login/oauth2/code/gitlab) and the scope openid ("authenticate using OpenID Connect"). Save, and copy the client id and client secret. The secret is shown only once; you can renew it later, which invalidates the old one.
Auth0: create an application the same way; OIDC is on, and the signing algorithm can be RS256 (private key signs, public key verifies).
Step 2: dependency and configuration
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>properties
## GitLab
spring.security.oauth2.client.registration.gitlab.client-id=${GITLAB_CLIENT_ID}
spring.security.oauth2.client.registration.gitlab.client-secret=${GITLAB_CLIENT_SECRET}
spring.security.oauth2.client.registration.gitlab.scope=openid
spring.security.oauth2.client.registration.gitlab.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.gitlab.redirect-uri=http://localhost:8080/login/oauth2/code/gitlab
spring.security.oauth2.client.provider.gitlab.authorization-uri=https://gitlab.com/oauth/authorize
spring.security.oauth2.client.provider.gitlab.token-uri=https://gitlab.com/oauth/token
spring.security.oauth2.client.provider.gitlab.issuer-uri=https://gitlab.com
spring.security.oauth2.client.provider.gitlab.jwk-set-uri=https://gitlab.com/oauth/discovery/keys
## Auth0 (your tenant's domain)
spring.security.oauth2.client.registration.auth0.client-id=${AUTH0_CLIENT_ID}
spring.security.oauth2.client.registration.auth0.client-secret=${AUTH0_CLIENT_SECRET}
spring.security.oauth2.client.registration.auth0.scope=openid,profile
spring.security.oauth2.client.registration.auth0.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.auth0.redirect-uri=http://localhost:8080/login/oauth2/code/auth0
spring.security.oauth2.client.provider.auth0.authorization-uri=https://your-tenant.us.auth0.com/authorize
spring.security.oauth2.client.provider.auth0.token-uri=https://your-tenant.us.auth0.com/oauth/token
spring.security.oauth2.client.provider.auth0.issuer-uri=https://your-tenant.us.auth0.com/
spring.security.oauth2.client.provider.auth0.jwk-set-uri=https://your-tenant.us.auth0.com/.well-known/jwks.jsongitlab and auth0 are registration ids, your names for each provider. Per registration: the client's id and secret, the scope, the grant type and the redirect URI. Per provider: the /authorize URL, the token URL, the issuer (who issues the tokens) and the JWK set URI (where the provider publishes the public keys that verify its signatures). Keep secrets out of the file; read them from environment variables as above. (With issuer-uri set, Spring can also discover the other URLs from the provider's /.well-known/openid-configuration.)
Step 3: security configuration
java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.csrf(csrf -> csrf.disable())
.oauth2Login(Customizer.withDefaults());
return http.build();
}
}java
@RestController
public class UserDetailsController {
@GetMapping("/")
public String defaultHomePageMethod() {
return "hello, you are logged in";
}
@GetMapping("/users")
public String getUsersDetails() {
return "fetched the details successfully";
}
}No user is created in our app; the users live at GitLab or Auth0. Open http://localhost:8080: you are sent to /login, which lists Auth0 and GitLab (with a single provider configured, Spring skips the list and redirects straight to it). Pick one, log in there, consent, and you are back and logged in; /users works too. Spring Security implements the whole authorization code flow; we wrote no flow code.
What happens inside
/login:DefaultLoginPageGeneratingFilterbuilds the page from the configured registrations.Clicking a provider calls
/oauth2/authorization/{registrationId}.OAuth2AuthorizationRequestRedirectFilterredirects the browser to the provider:https://gitlab.com/oauth/authorize?response_type=code&client_id=...&scope=openid&state=... &redirect_uri=http://localhost:8080/login/oauth2/code/gitlab&nonce=...(
stateprotects the flow against CSRF;nonceties the ID token to this login.)The user logs in and consents at the provider, which redirects to our redirect URI with the authorization code.
OAuth2LoginAuthenticationFilterhandles the callback. It builds an authentication object holding the code and passes it to theAuthenticationManager. With scopeopenid,OidcAuthorizationCodeAuthenticationProvidersupports it: it calls the token URI with the client id, secret and code, and receives the access token (GitLab's is opaque: no dots, meaningful only to GitLab), a refresh token and the ID token (a JWT carrying the username, profile picture and similar).The filter stores the tokens in the
OAuth2AuthorizedClientService(in memory by default; you can implement it to store them in a database), creates an HTTP session holding the security context, and redirects to/.Later requests send the session cookie;
SecurityContextHolderFilterrestores the context from the session, as in form login.
The twist: it is stateful
Calling /users from Postman (no browser, no cookie) lands on the login page again. Spring assumes OAuth2 login happens in a browser, so it keeps a session:
- Clients without the session cookie are not recognized.
- While the session is valid, the tokens are not checked again: the ID token could expire or be revoked while the session stays active.
Making it stateless
Plan: after login, return the ID token to the client; the client sends it as Authorization: Bearer <token> on every request; we validate it every time.
Return the ID token
OAuth2LoginAuthenticationFilter's parent calls an AuthenticationSuccessHandler after a successful login (by default it just redirects and cleans up). Provide your own:
java
@Component
public class CustomOAuth2SuccessHandler implements AuthenticationSuccessHandler {
private final OAuth2AuthorizedClientService clientService;
public CustomOAuth2SuccessHandler(OAuth2AuthorizedClientService clientService) {
this.clientService = clientService;
}
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException {
OAuth2AuthenticationToken authToken = (OAuth2AuthenticationToken) authentication;
OAuth2AuthorizedClient client = clientService.loadAuthorizedClient(
authToken.getAuthorizedClientRegistrationId(), authToken.getName());
if (client != null) {
String idToken = null;
if (authToken.getPrincipal() instanceof OidcUser oidcUser) {
idToken = oidcUser.getIdToken().getTokenValue();
}
response.setContentType("application/json");
response.getWriter().write("{ \"id_token\": \"" + idToken + "\" }");
response.getWriter().flush();
} else {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Authorization failed");
}
}
}Validate the token on every request
java
public class OAuthValidationFilter extends OncePerRequestFilter {
private final OAuthTokenValidator tokenValidator;
public OAuthValidationFilter(OAuthTokenValidator tokenValidator) {
this.tokenValidator = tokenValidator;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = extractJwtFromRequest(request);
if (token != null) {
String username = tokenValidator.validateAndGetSubject(token);
if (username == null) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid or expired token");
return;
}
Authentication auth = new UsernamePasswordAuthenticationToken(username, null, List.of());
SecurityContextHolder.getContext().setAuthentication(auth);
}
filterChain.doFilter(request, response);
}
private String extractJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
return (bearerToken != null && bearerToken.startsWith("Bearer ")) ? bearerToken.substring(7) : null;
}
}Validate against trusted issuers only
The ID token is signed by the provider with its private key; we verify it with the provider's public key. The tempting shortcut is to read the issuer (iss) from the token and call JwtDecoders.fromIssuerLocation(iss). That method does not look anything up in application.properties: it runs OIDC discovery against whatever URL it is given and trusts the keys found there.
So an attacker can run their own "issuer" (a discovery document plus a key set), sign a token for any username with their own key, and put their URL in iss. The shortcut fetches the attacker's public key and accepts the token. Tested: a forged token from such an issuer got 200 on /users. This is the key-substitution mistake from the JWT chapter again: never let the token decide which key verifies it.
Accept only the issuers you configured, and check that the token was issued for your app (aud equals your client id; ID tokens for other apps from the same provider must not work here):
java
@Component
public class OAuthTokenValidator {
// issuer -> our client id at that issuer
private final Map<String, String> trusted = Map.of(
"https://gitlab.com", "<gitlab client id>",
"https://your-tenant.us.auth0.com/", "<auth0 client id>");
private final Map<String, JwtDecoder> decoders = new ConcurrentHashMap<>();
public String validateAndGetSubject(String token) {
try {
String issuer = JWTParser.parse(token).getJWTClaimsSet().getIssuer(); // unverified: only used to pick a trusted issuer
String clientId = trusted.get(issuer);
if (clientId == null) {
return null; // unknown issuer: reject
}
JwtDecoder decoder = decoders.computeIfAbsent(issuer,
iss -> NimbusJwtDecoder.withIssuerLocation(iss).build()); // keys from the trusted issuer
Jwt jwt = decoder.decode(token); // signature, expiry, issuer
return jwt.getAudience().contains(clientId) ? jwt.getSubject() : null; // meant for us?
} catch (Exception e) {
return null;
}
}
}With this validator the forged token gets 401.
The configuration
java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http, CustomOAuth2SuccessHandler successHandler,
OAuthTokenValidator tokenValidator) throws Exception {
http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(csrf -> csrf.disable())
.oauth2Login(oauth -> oauth.successHandler(successHandler))
.addFilterBefore(new OAuthValidationFilter(tokenValidator), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}Now, after logging in with GitLab, the browser receives { "id_token": "eyJ..." }, and any client can call /users with Authorization: Bearer <id_token>.
(The login flow itself still uses a short-lived session to remember state between the redirect and the callback; STATELESS only stops Spring Security from keeping the authenticated user in a session.)
For APIs that only accept tokens (and never run the login), Spring has a ready-made alternative to the custom filter: spring-boot-starter-oauth2-resource-server with spring.security.oauth2.resourceserver.jwt.issuer-uri (and .audiences) and .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())). It validates signature, expiry, issuer and audience against the configured issuer.
Next: All authorization so far was written as URL rules in the security configuration. Part 9, the last security chapter, moves it onto the methods themselves with @PreAuthorize and @PostAuthorize.
Check yourself
- What is the difference between OAuth2 and OIDC? Which tokens does each give?
- Why is the ID token meant for the client app rather than the resource server?
- What do you get from registering the app with GitLab, and what is the redirect URI for?
- What is a registration id, and what are the
issuer-uriandjwk-set-urifor? - Which filter handles
/oauth2/authorization/gitlab, and what does it do? - Which filter and provider handle the callback, and what does the provider call?
- Why does calling
/usersfrom Postman send you to the login page? - What is the risk of relying on the session after an OAuth2 login?
- How do you return the ID token to the client after login?
- Why is
JwtDecoders.fromIssuerLocation(<iss from the token>)dangerous, and what must the validator check instead?
Answers
- OAuth2 is for authorization (access to user data) and gives an access token; OIDC adds authentication on top and gives an ID token (JWT) plus an access token.
- It carries the minimal user information the client needs to authenticate the user; APIs are called with the access token.
- A client id and client secret. The redirect URI is where the provider sends the user back with the authorization code.
- Your name for a provider configuration (
gitlab,auth0). The issuer identifies who issues tokens; the JWK set URI is where its public keys for verifying signatures are published. OAuth2AuthorizationRequestRedirectFilter; it redirects the browser to the provider's authorization URL with client id, scope, state, redirect URI and nonce.OAuth2LoginAuthenticationFilterwithOidcAuthorizationCodeAuthenticationProvider, which calls the token URI with the code, client id and secret.- The login created a server-side session; Postman sends no session cookie.
- Tokens are not re-checked while the session is valid, so an expired or revoked token can still leave the user logged in.
- With a custom
AuthenticationSuccessHandlerthat writes the ID token into the response. - It trusts whatever issuer the token names, so an attacker's own issuer and key make a forged token valid. Accept only configured issuers, verify with their keys, and check that
audis your client id.