1. OAuth2 & OIDC Concepts
Before writing any Spring code, it's worth being precise about what OAuth2 actually solves, because it's commonly misused as a synonym for "login." OAuth2 is, at its core, an authorization framework — it lets an application obtain limited access to a resource on a user's behalf, without ever seeing that user's password. "Sign in with Google" is one application of that framework, not the whole of it.
Three roles
Every OAuth2 flow involves three actors talking to each other. The authorization server (Google, GitHub, Okta, your own identity provider) authenticates the user and issues tokens. The resource server hosts the protected data the client wants — often the same server as the authorization server for a big provider like Google, but a separate system entirely in an enterprise setup. The client is the application requesting access on the user's behalf — in this course, your Spring Boot Task API.
The authorization code flow, conceptually
The flow your browser goes through when you click "Sign in with Google" follows a consistent shape, regardless of provider:
1. Browser hits your app's "Login with Google" link
-> redirected to accounts.google.com with your app's client-id
2. User logs in at Google (your app never sees their Google password)
and approves the permissions your app is requesting
3. Google redirects back to your app with a one-time authorization code
GET /login/oauth2/code/google?code=4/0AY0e-g7...
4. Your app's backend exchanges that code for tokens -- server-to-server,
never visible to the browser -- by calling Google's token endpoint
with the code, client-id, and client-secret
5. Google returns an access token (and, for OIDC, an ID token)
-> your app now knows who the user is and can call Google APIs
on their behalf, within the granted scope
The authorization code that bounces through the browser in step 3 is deliberately short-lived and useless on its own — it can only be redeemed for real tokens by a client that also knows the client secret, in a direct server-to-server call the browser never sees. That's what keeps the actual access token out of the browser's history and any JavaScript that might be running on the page.
What OIDC adds: identity on top of authorization
Plain OAuth2 answers "can this app access this resource?" — it was never designed to
answer "who is this person?" OpenID Connect (OIDC) is a thin
identity layer built on top of OAuth2 that closes exactly that gap: alongside the
access token, the authorization server also issues an ID token, a
signed JWT containing standardized claims about the user — sub (a
stable unique identifier), email, name,
picture, and so on. That ID token is what actually lets your app say
"this session belongs to ada@example.com" with cryptographic confidence, rather than
inferring identity indirectly from what an access token happens to unlock.
This distinction is why nearly every "social login" integration you've used is OIDC, not bare OAuth2 — and why teams reach for it instead of rolling their own social login: the provider has already solved multi-factor auth, account recovery, breach detection, and consent screens, and hands you back a standardized, signed identity token instead of a pile of provider-specific quirks to reverse-engineer.
OAuth2/OIDC login is an alternative front door, not a rewrite of what you built last week. The JWT-based username/password flow from Week 6 keeps working exactly as before -- you're adding a second way for a user to arrive at an authenticated session, not removing the first.
2. OAuth2 Login with Spring Security
Spring Security implements the entire authorization code flow from section 1 for you — redirects, state/nonce validation, the server-to-server token exchange, and ID token verification — behind one starter and a handful of configuration properties.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
Registering a provider
Google (and most major providers) are pre-configured in Spring Boot's OAuth2 client support -- you only need to supply the credentials you get from Google's developer console, plus the scopes you're requesting:
spring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID}
spring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET}
spring.security.oauth2.client.registration.google.scope=openid,profile,email
Just like the JWT secret in Week 6, these credentials are loaded from environment variables rather than hard-coded -- a client secret checked into source control is a standing invitation for anyone with repo access to impersonate your app to Google.
The endpoints Spring Boot wires up for free
Registering a provider auto-configures two URLs, and you never write a controller
for either of them -- Spring Security's oauth2Login() DSL handles both:
GET /oauth2/authorization/google
-> starts the flow: redirects the browser to Google's consent screen
GET /login/oauth2/code/google
-> the redirect URI Google sends the browser back to with the
authorization code; Spring Security exchanges it for tokens here
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.sessionManagement(sm ->
sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/login", "/auth/register", "/oauth2/**", "/login/**").permitAll()
.anyRequest().authenticated())
.oauth2Login(oauth2 -> oauth2
.successHandler(oAuth2LoginSuccessHandler)) // section below
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
Bridging OAuth2User into your own User table
After a successful OAuth2 login, Spring Security hands you an
OAuth2User (or, for OIDC providers like Google, an
OidcUser) populated with the provider's claims -- but that object isn't
your User entity from Week 6, and it isn't persisted anywhere. A custom
success handler is the bridge: it looks up or creates a row in the same
app_user table, then issues the exact same kind of JWT your
/auth/login endpoint issues, so everything downstream of login -- the
JWT filter, the Task endpoints, method-level authorization -- treats both login
paths identically.
package com.codeverse.week07.security;
import com.codeverse.week07.user.Role;
import com.codeverse.week07.user.User;
import com.codeverse.week07.user.UserRepository;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import java.io.IOException;
@Component
public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler {
private final UserRepository userRepository;
private final JwtService jwtService;
public OAuth2LoginSuccessHandler(UserRepository userRepository, JwtService jwtService) {
this.userRepository = userRepository;
this.jwtService = jwtService;
}
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException {
OidcUser oidcUser = (OidcUser) authentication.getPrincipal();
String email = oidcUser.getEmail();
User user = userRepository.findByUsername(email)
.orElseGet(() -> {
User created = new User();
created.setUsername(email);
created.setPasswordHash(null); // no local password -- OAuth2-only account
created.setRole(Role.USER);
return userRepository.save(created);
});
String token = jwtService.generateToken(user.getUsername());
// Hand the token back the same shape /auth/login uses, so the frontend
// doesn't need two different response formats to deal with.
response.setContentType("application/json");
response.getWriter().write("{\"token\": \"" + token + "\"}");
}
}
Note the orElseGet — the first time a given Google account logs in, it
silently provisions a matching User row with a null password hash
(there's nothing to hash; this account never sets a local password) and the default
Role.USER. Every login after that finds the existing row by email
instead of creating duplicates.
4. CORS & CSRF in a Stateless API
These two acronyms get confused constantly, partly because both are browser security
mechanisms and both showed up already in this week's and last week's
SecurityFilterChain config -- but they defend against different threats,
and a stateless, token-based API treats them very differently.
Why CSRF is disabled here
CSRF (Cross-Site Request Forgery) exploits the fact that browsers automatically
attach cookies to every request to a domain, including requests triggered by a
malicious third-party page the user happens to have open. If your API authenticated
via a session cookie, a hostile page could silently trigger
POST /api/tasks/1/delete and the browser would obligingly attach your
session cookie, with no way for the server to tell that request apart from one you
made yourself -- that's exactly what CSRF tokens exist to prevent.
This Task API doesn't have that problem, by construction. Every request --
JWT-authenticated or OAuth2-derived -- carries its credential in an
Authorization: Bearer <token> header, which the browser never
attaches automatically the way it does cookies; a malicious page has no way to make
the victim's browser send that header on its behalf. No cookies for authentication
means no ambient credential for an attacker to ride on, which is precisely why
csrf(csrf -> csrf.disable()) was correct back in Week 6 and stays
correct this week: there's no CSRF risk to defend against in a stateless,
header-authenticated API.
Why CORS still matters
CORS (Cross-Origin Resource Sharing) is a completely different concern: it's the
browser's own default-deny policy for cross-origin fetch/XHR calls made
from JavaScript, regardless of how those calls are authenticated. If your frontend is
served from http://localhost:4200 (an Angular or React dev server) and
your API runs on http://localhost:8080, those are different origins by
the browser's definition -- and without an explicit CORS policy telling the browser
"requests from that origin are allowed," the browser blocks the frontend's JavaScript
from reading the response, even if the API itself would have happily served it.
package com.codeverse.week07.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
@Configuration
public class CorsConfig {
@Value("${app.cors.allowed-origins}")
private List<String> allowedOrigins;
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(allowedOrigins); // explicit list, never "*"
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
}
app.cors.allowed-origins=http://localhost:4200
Once requests carry a real identity -- whether from Week 6's JWT login or this week's OAuth2 flow -- that identity becomes something worth recording, not just checking. Week 12 covers auditing: capturing who created, updated, or deleted a given row, using the same authenticated principal this week's @PreAuthorize checks already rely on.
Wiring the bean into the filter chain is one line -- Spring Security picks up a
CorsConfigurationSource bean automatically once you reference it:
return http
.cors(cors -> cors.configurationSource(corsConfigurationSource))
.csrf(csrf -> csrf.disable())
// ...rest unchanged from section 2
.build();
Never wildcard everything in production
setAllowedOrigins(List.of("*")) looks tempting during local development
because it "just works" against any frontend, but it's a production liability the
moment credentials are involved -- covered in the quiz below. Keep the allowed-origin
list explicit and environment-specific: localhost:4200 locally,
your real frontend's domain in production, loaded from configuration exactly like
the JWT secret and OAuth2 client credentials.
5. Hands-on Exercise
Add roles, method-level authorization, and CORS to the Task API
Extend the JWT-secured Task API from Week 6 with real authorization: roles, ownership checks, a browser-facing CORS policy, and -- optionally -- OAuth2 login as a second way in.
Requirements:
- Add a
Roleenum (USER,ADMIN) and arolefield on the Week 6Userentity, defaulting new registrations toUSER. Seed at least oneADMINuser directly in the database or via a startupCommandLineRunner. - Add
@EnableMethodSecurityand implement adeleteTask(Long taskId)that regular users can only call successfully against tasks they own, while anADMINcan delete any task -- using@PreAuthorizewith a SpEL expression referencing an ownership-check helper bean, following theTaskSecuritypattern above. - Add a separate admin-only endpoint (e.g.
DELETE /api/admin/tasks/{id}) guarded with@PreAuthorize("hasRole('ADMIN')"), and confirm withcurlthat aUSERtoken gets a 403 while anADMINtoken succeeds. - Configure a
CorsConfigurationSourcebean that allows your local frontend's origin (e.g.http://localhost:4200) with an explicit origin list -- not a wildcard -- and wire it into theSecurityFilterChainwith.cors(...). - (Optional, extra credit) Add
spring-boot-starter-oauth2-client, register a Google OAuth2 client, and implement anAuthenticationSuccessHandlerthat finds-or-creates aUserrow by email and issues the same JWT format as/auth/login, so a frontend can treat both login paths identically.
Your Week 6 JwtAuthenticationFilter and UserDetailsService don't need to change for roles to work -- just make sure loadUserByUsername maps the entity's role field onto .authorities("ROLE_" + user.getRole()) instead of the hard-coded "ROLE_USER" string from last week.
6. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
What does OIDC add on top of plain OAuth2, and why does it matter for "Sign in with Google"-style login?
What does OIDC add on top of plain OAuth2, and why does it matter for "Sign in with Google"-style login?
Plain OAuth2 is an authorization framework -- it's built to answer "can this app access this resource on the user's behalf?" and issues an access token scoped to that resource, without any standardized notion of who the user actually is. OIDC layers identity on top: alongside the access token, the authorization server also issues a signed ID token, a JWT carrying standardized claims like sub, email, and name. That ID token is what actually answers "who is this person?" with cryptographic confidence, which is the piece "Sign in with Google" fundamentally needs -- without OIDC, an app would only know it was granted some scope of access, not whose account it belongs to.
Q2
What's the practical difference between a @PreAuthorize role check and an authorizeHttpRequests URL pattern rule?
What's the practical difference between a @PreAuthorize role check and an authorizeHttpRequests URL pattern rule?
authorizeHttpRequests rules live in the security filter chain and run before any controller code executes -- they can only reason about the request's path and method, which makes them ideal for coarse, blanket rules like "everything under /api/admin/** requires the ADMIN role." @PreAuthorize runs at the method itself, with full access to the method's actual arguments (and, via a SpEL helper bean, to data fetched from the database), so it can express rules URL patterns fundamentally can't -- like "this specific task can only be deleted by its owner or an admin," where the answer depends on the resource, not just the path. In practice they're complementary: broad gates at the filter chain, precise ownership checks at the method that touches the resource.
Q3
Why is CSRF protection typically disabled for a token-based API, while CORS still needs to be configured?
Why is CSRF protection typically disabled for a token-based API, while CORS still needs to be configured?
CSRF attacks work by exploiting the browser's habit of automatically attaching cookies to every request to a given domain, including requests a malicious third-party page silently triggers -- so a session-cookie-authenticated app is vulnerable unless it defends against forged requests riding on that ambient cookie. This Task API never authenticates via cookies; every request carries its credential explicitly in an Authorization: Bearer header, which the browser does not attach automatically the way it does cookies, so a malicious page has no way to forge that header on a victim's behalf -- there's no ambient credential to ride on, hence no CSRF risk to defend against. CORS is unrelated: it's the browser's own default-deny policy for any cross-origin JavaScript call, regardless of how it's authenticated, so a legitimate frontend served from a different origin than the API still needs an explicit CORS policy or the browser blocks its requests outright.
Q4
Why is a wildcard CORS origin ("*") dangerous once endpoints require credentials?
Why is a wildcard CORS origin ("*") dangerous once endpoints require credentials?
An allowed origin of "*" tells the browser that a script running on literally any website is permitted to read responses from your API -- fine for a truly public, unauthenticated endpoint, but catastrophic once requests carry a bearer token or credentials, because any malicious site the victim happens to have open could make an authenticated call to your API from the victim's browser and read the response back into its own JavaScript. Browsers actually enforce this: the CORS spec forbids combining a wildcard origin with allowCredentials(true), and a compliant browser will reject that combination outright. The safe pattern is an explicit, environment-specific allow-list -- localhost:4200 in development, your real frontend's domain in production -- so only origins you actually trust can read authenticated responses.