Secure the backend
Keep the IdP running, and in a second terminal:
$ cd bikeshare-security/backend
$ ./gradlew bootRun
This starts on port 8080: a tiny, in-memory bike API (GET /api/bikes, POST /api/bikes/{id}/rentals, POST /api/bikes/{id}/telemetry), with no data access of its own, so the lesson stays about security.
Pointing at the IdP
In backend/src/main/resources/application.yaml:
1spring:
2 security:
3 oauth2:
4 resourceserver:
5 jwt:
6 issuer-uri: http://localhost:9000That single property is enough for Spring Boot to fetch the IdP’s metadata, find its signing key, and build a JwtDecoder automatically: no manual JWK handling, no copy-pasted public key.
This is the resource-server half of the relationship whose authorization-server half you saw in Run the IdP.
Scope-based authorization
Open SecurityConfig.java.
1http.authorizeHttpRequests(authorize -> authorize
2 .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
3 .requestMatchers(HttpMethod.GET, "/api/bikes").hasAuthority("SCOPE_bikes:read")
4 .requestMatchers(HttpMethod.POST, "/api/bikes/*/rentals").hasAuthority("SCOPE_bikes:write")
5 .requestMatchers(HttpMethod.POST, "/api/bikes/*/telemetry").hasAuthority("SCOPE_telemetry:write")
6 .anyRequest().denyAll())
7 .cors(Customizer.withDefaults())
8 .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));Three things, none of which needed custom code:
-
oauth2ResourceServer(…).jwt(…)rejects any request without a valid, unexpired, correctly signed JWT, the same way.formLogin(…)rejected requests without a session in Filter chain. -
Each
SCOPE_xxxauthority comes straight from the token’sscopeclaim: Spring Security’s default JWT authentication converter turns every scope into one authority, prefixed withSCOPE_. -
.anyRequest().denyAll()is deliberate: nothing is reachable unless a rule above explicitly allows it.
Try it with the token you already have
Reuse the bike-sensor access token from the previous page (request a fresh one if it expired, they only live 60 seconds):
$ TOKEN=$(curl -s -u bike-sensor:sensor-secret \
-d grant_type=client_credentials -d scope=telemetry:write \
http://localhost:9000/oauth2/token | python3 -c "import json,sys;print(json.load(sys.stdin)['access_token'])")
$ curl -i -X POST http://localhost:8080/api/bikes/bike-1/telemetry \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"batteryLevel": 42, "latitude": 45.0, "longitude": 4.0, "motorRpm": 120}'
You should see 204 No Content.
Predict, then try:
-
Call
GET /api/bikeswith that same token. It carriestelemetry:write, notbikes:read, so you should get403 Forbidden, not401: the token is valid, it simply was not authorized for this. -
Call
GET /api/bikeswith noAuthorizationheader at all. Compare the status code to the previous one: a missing/invalid token is401 Unauthorized; a valid token lacking the right scope is403 Forbidden.
The automated tests in BikeControllerTest exercise exactly these cases with mocked JWTs, without needing a running IdP; run them with ./gradlew test.