How To Build an OAuth 2 Client with Spring Boot 3
Intro
Hey friends! This is a jam-packed tutorial about using Spring Boot 3, the new RestClient
, and the OAuth 2 client, to connect to the YouTube API. We built an application that updates the title of a video to always* reflect the current number of views. The app is even doing this in the background, so we have to extract the access token, which otherwise would only be available during the lifetime of a request!
- well.. not always, as we will see, due to API limitations
Setup
We must create a new project in the Google Cloud Console to access the YouTube API. This is because the calls require additional permissions, which we can only get by using OAuth 2.
In the Google Cloud Console, we must enable the YouTube Data API before using it. Once done, we need to create a new credential. This requires filling in some data on the OAuth consent screen, such as name, email addresses, and scopes - which we leave empty for now. We are building a web application and must provide an authorized redirect URI. Since the app is running locally, we configure it to http://localhost:8080/login/oauth2/code/google
.
Once done, we download the credentials file that includes the client ID and the client secret, among other things.
With these details, we can configure the OAuth client.
Let’s Code
The Spring OAuth2 client has a preset for Google, so we don’t have to provide any URLs (for now). However, we must extend the scope to access the YouTube API, so we add the scope https://www.googleapis.com/auth/youtube
to our config.
spring: security: oauth2: client: registration: google: clientId: google-client-id clientSecret: google-client-secret scope: openid,profile,email,https://www.googleapis.com/auth/youtube
If we start the app now and access any arbitrary endpoint, we will be prompted to authenticate using Google. So, the whole flow is already working!
To let the application access our account later on, in the background, we need to find out our Google account ID. This is the principal’s name that is part of the security context. So we can add a simple controller and inject the authentication so we can read the ID. We take note of it since we will need it later.
package dev.axgr import org.springframework.security.core.context.SecurityContextHolderimport org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationTokenimport org.springframework.web.bind.annotation.GetMappingimport org.springframework.web.bind.annotation.RestController @RestControllerclass AuthController { @GetMapping("/me") fun me(): String { val auth = SecurityContextHolder .getContext() .authentication as? OAuth2AuthenticationToken return auth?.name ?: "anonymous" }}
The authentication is only available as part of the current request. When the application is running in the background, there is no request. We will work around this in a bit and need the principal name for this.
Let’s build the YouTube service component that is performing the actual requests. It has a function to fetch the details of a single video and a function to update a single video.
The HTTP calls are performed by the Spring RestClient
, which we first have to add to the Spring context to use it.
Below is our configuration.
package dev.axgr import org.springframework.boot.context.properties.ConfigurationPropertiesimport org.springframework.boot.context.properties.EnableConfigurationPropertiesimport org.springframework.context.annotation.Beanimport org.springframework.context.annotation.Configurationimport org.springframework.web.client.RestClient @ConfigurationProperties("youtube")data class YouTubeProperties(val url: String, val video: String, val title: String, val principal: String) @Configuration@EnableConfigurationProperties(YouTubeProperties::class)class YouTubeConfig(private val props: YouTubeProperties) { @Bean fun client(builder: RestClient.Builder): RestClient { return builder .baseUrl(props.url) .build() }}
We add custom properties for the video we want to update, the title (a String template), and the principal name we extracted earlier.
The RestClient
is now available, and this is what the calls look like:
package dev.axgr import org.springframework.stereotype.Serviceimport org.springframework.web.client.RestClient @Serviceclass YouTube(private val client: RestClient) { fun details(id: String): Video? { val response = client.get() .uri { it .path("/videos") .queryParam("id", id) .queryParam("part", "snippet,contentDetails,statistics") .build() } .retrieve() .body(VideoListResponse::class.java) return response?.items?.firstOrNull() } fun update(video: Video, title: String) { val request = VideoUpdateRequest(video.id, title, video.category) client.put() .uri { it .path("/videos") .queryParam("part", "snippet,status,localizations") .build() } .body(request) .retrieve() .toBodilessEntity() }}
The YouTube API returns a list of videos, so we extract the first result or return null
if there is no result. The update function is a PUT call - note that this requires sending all relevant properties back to the API. We will only update the title in our current implementation, so this is fine. If you also wanted to preserve descriptions, tags, etc., these must also be passed.
Now that the integration is ready, we must provide the RestClient
with an access token. This is best done using a request interceptor invoked around each HTTP request. This allows us to inspect or manipulate the request and the response.
package dev.axgr import org.springframework.http.HttpHeadersimport org.springframework.http.HttpRequestimport org.springframework.http.client.ClientHttpRequestExecutionimport org.springframework.http.client.ClientHttpRequestInterceptorimport org.springframework.http.client.ClientHttpResponseimport org.springframework.security.oauth2.client.OAuth2AuthorizedClientimport org.springframework.security.oauth2.client.OAuth2AuthorizedClientServiceimport org.springframework.stereotype.Component @Componentclass OAuth2TokenProvider(private val service: OAuth2AuthorizedClientService, val props: YouTubeProperties) : ClientHttpRequestInterceptor { override fun intercept(request: HttpRequest, body: ByteArray, execution: ClientHttpRequestExecution): ClientHttpResponse { val token = token() if (token != null) { request.headers.add(HttpHeaders.AUTHORIZATION, "Bearer $token") return execution.execute(request, body) } throw IllegalStateException("No OAuth2 authentication found.") } private fun token(): String? { val client: OAuth2AuthorizedClient? = service.loadAuthorizedClient("google", props.principal) return client?.accessToken?.tokenValue }}
The authorized OAuth client is stored in a service, and we can get it by specifying the provider name, Google in our case, and the principal for whom we want the credentials - which happens to be the ID we noted earlier.
This gives us access to the access token and the interceptor can set the header accordingly.
Time to add the background job! We need to enable scheduling and can have a job that is performing our updates.
It would be nice to have the title being updated in real-time, but there is a catch. Reading a video costs 1 unit, updating a video costs 10, and a project has a limit of 10k units per day.
Reading and updating costs us 51 units, and doing this every second would exceed the limit in the first hour. Instead, we will update it every 10 minutes, which gives us:
6 * 51 * 24 = 7344 units/day
So we should be safe.
We can further optimize that by adding a simple caching variable: if the views haven’t changed since the last run, we don’t invoke the update call and save the 50 units. Win!
Now, we can start the app and authenticate at least once. Afterward, the background job runs the updates!
Offline Access
Here is the thing: the access tokens are short-lived, as they usually are. But we can request a refresh token from Google to mint new access tokens ourselves. This requires to pass an additional scope to YouTube:
spring: security: oauth2: client:
registration: google: clientId: google-client-id clientSecret: google-client-secret scope: openid,profile,email,https://www.googleapis.com/auth/youtube provider: google: authorizationUri: https://accounts.google.com/o/oauth2/v2/auth?access_type=offline&prompt=consent
Essentially, we are overriding the authorization URI to request offline access. This way, we will get a refresh token back from the API, which we can access using the client service.
With the refresh token we can request a new access token (i.e., whenever the current one has expired) by posting a request to their API:
POST /token HTTP/1.1Host: oauth2.googleapis.comContent-Type: application/x-www-form-urlencoded client_id=your_client_id&client_secret=your_client_secret&refresh_token=refresh_token&grant_type=refresh_token
With that in place the app is smoothly running in the background without further intervention.