Secure the backend

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:9000

That 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_xxx authority comes straight from the token’s scope claim: Spring Security’s default JWT authentication converter turns every scope into one authority, prefixed with SCOPE_.

  • .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:

  1. Call GET /api/bikes with that same token. It carries telemetry:write, not bikes:read, so you should get 403 Forbidden, not 401: the token is valid, it simply was not authorized for this.

  2. Call GET /api/bikes with no Authorization header at all. Compare the status code to the previous one: a missing/invalid token is 401 Unauthorized; a valid token lacking the right scope is 403 Forbidden.

The automated tests in BikeControllerTest exercise exactly these cases with mocked JWTs, without needing a running IdP; run them with ./gradlew test.