spring-bootkotlinspring-securityoauth-2.0msal4j

Headless OAuth2 Token Refresh Fails with Spring boot and MSAL4J


Edited the original question to provide a clearer overview and better understanding of the problem. Thanks to @Steve Riesenberg and @Toerktumlare for guiding me in the right direction.

I developed a Kotlin-Spring Boot application that interacts with the Microsoft Graph API. Users authenticate via a browser, after which scheduled background tasks run on their behalf. These tasks require a seamless token refresh process.

While the initial token retrieval worked, I encountered issues when trying to refresh the token during these scheduled tasks in a headless (non-interactive) environment. My primary goal was to ensure that token refreshes worked reliably without user interaction.

I’ve provided one way to handle this in my own answer below, which I marked as accepted.


Solution

  • Most guides and examples online were for older versions of the Microsoft Graph SDK and didn’t work with MSAL4J 6.x. It took me a while to find the upgrade guide.

    There are quite a few ways to authenticate, which can be confusing. I had to figure out which authentication provider suited my needs. Microsoft Graph authentication provider

    Here’s one approach that worked for me.

    First of all, i just need microsoft-graph and msal4j (Thanks to @Steve Riesenberg and @Toerktumlare)

    build.gradle

    ...
    implementation("com.microsoft.graph:microsoft-graph:6.20.0")
    implementation("com.microsoft.azure:msal4j:1.17.2")
    ...
    

    I used the "Authorization Code Flow" for the initial login via the browser. The "Confidential Client Application" was used to store and renew the token.

    Rest Endpoints for initial authentication via browser:

    @RestController
    class GraphRestAuth(
        val config: TaskControllerConfig,
        val graphAuthProvider: GraphWebAuthProvider
    ) {
        @GetMapping("/", "/login")
        fun login(): ResponseEntity<Any> {
            val authUrl = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize" +
                    "?client_id=XXX" +
                    "&response_type=code" +
                    "&redirect_uri=http://localhost:8080/redirect" +
                    "&response_mode=query" +
                    "&scope=https://graph.microsoft.com/.default"
            return ResponseEntity.status(HttpStatus.FOUND).header(HttpHeaders.LOCATION, authUrl).build()
        }
    
        @GetMapping("/redirect")
        fun handleRedirect(@RequestParam code: String): String {
            val graphClient = graphAuthProvider.initializeWithAuthCode(code)
            val user = graphClient.me().get()
            return "Hello, ${user.displayName}!"
        }
    }
    

    Handle token and caching:

    @Component
    class GraphWebAuthProvider(
        val config: TaskControllerConfig
    ) {
        private var tokenExpireDate: OffsetDateTime? = null
        private var graphClient: GraphServiceClient? = null
    
        fun initializeWithAuthCode(authCode: String): GraphServiceClient {
            val tokenCacheAspect = TokenCacheAspect("/app-config/tokenCache.json")
            val clientCredential = ClientCredentialFactory.createFromSecret("XXX")
    
            val cca = ConfidentialClientApplication.builder("XXX", clientCredential)
                .authority("https://login.microsoftonline.com/common")
                .setTokenCacheAccessAspect(tokenCacheAspect)
                .build()
    
            val parameters = AuthorizationCodeParameters.builder(
                authCode, URI.create("http://localhost:8080/redirect")
            ).scopes(setOf("https://graph.microsoft.com/.default")).build()
    
            val result = cca.acquireToken(parameters).join()
            tokenExpireDate = result.expiresOnDate().toInstant().atOffset(OffsetDateTime.now().offset)
    
            val tokenCredential = SimpleTokenCredential(result.accessToken())
            graphClient = GraphServiceClient(tokenCredential)
            return graphClient!!
        }
    
        fun refreshAccessToken() {
            val tokenCacheAspect = TokenCacheAspect("/app-config/tokenCache.json")
            val clientCredential = ClientCredentialFactory.createFromSecret("XXX")
    
            val cca = ConfidentialClientApplication.builder("XXX", clientCredential)
                .authority("https://login.microsoftonline.com/common")
                .setTokenCacheAccessAspect(tokenCacheAspect)
                .build()
    
            val accountsInCache = cca.accounts.join()
            val account = accountsInCache.first()
                ?: throw RuntimeException("No account in cache found.")
    
            try {
                val silentParameters = SilentParameters.builder(setOf("https://graph.microsoft.com/User.Read", "https://graph.microsoft.com/Tasks.Read", "https://graph.microsoft.com/Tasks.ReadWrite", "offline_access"), account).build()
                val result = cca.acquireTokenSilently(silentParameters).join()
                tokenExpireDate = result.expiresOnDate().toInstant().atOffset(OffsetDateTime.now().offset)
    
                val tokenCredential = SimpleTokenCredential(result.accessToken())
                graphClient = GraphServiceClient(tokenCredential)
            } catch (ex: Exception) {
                logger.error("Error in token refresh: ${ex.message}", ex)
            }
        }
    
        fun getGraphService(): GraphServiceClient {
            if (tokenExpireDate!!.isBefore(OffsetDateTime.now())) {
                refreshAccessToken()
            }
            return graphClient ?: throw IllegalStateException("GraphServiceClient could not be initialized.")
        }
    }
    
    

    After the app starts, the user logs in via localhost:8080/ and authenticates. Once logged in, the getGraphService method can be called anytime to obtain a valid GraphService and perform background tasks.