0

I'm creating a web application with Spring Boot that defines a REST API accessible via OAuth2 authentication for use with Google Assistant I configured DialogFlow (Webhook fulfillment configured with the URL to the endpoint of my REST API) I configured Actions on Google: I configured the Account Linking section with OAuth information (client ID, client Secret, Authorization URL, Token URL, Scopes ...)

I tested my application with my smartphone via the Google Home application. It tells me: "Before I can use "My App", I need to associate your "My App" account with Google. Do you agree with that?" I say, "Yes." I then have access to my web application for OAuth authentication. I validate, and it says: "Perfect! Your "My App" account is now connected to Google"

Then I write the sentence "Turn on my TV", it then calls the fulfillment webhook that calls my REST API.

Only I'm getting a request that doesn't seem right. I have an error indicating that the user is anonymous. It is as if the access-token had not been transmitted in the'Authorization' header. I can't find a way to get the complete request (Header + Body) that is sent.

I also tested on the Actions on Google Simulator but I only see the request body, not the headers. I looked at Google's logs but I don't have any more details.

Here are the logs :

19:47:41.263 [https-jsse-nio-9443-exec-6] DEBUG o.s.s.w.u.m.AntPathRequestMatcher - Request 'POST /api/fulfillment' doesn't match 'GET /**
19:47:41.264 [https-jsse-nio-9443-exec-6] DEBUG o.s.s.w.u.m.AntPathRequestMatcher - Request '/api/fulfillment' matched by universal pattern '/**'
19:47:41.264 [https-jsse-nio-9443-exec-6] DEBUG o.s.s.w.a.i.FilterSecurityInterceptor - Secure object: FilterInvocation: URL: /api/fulfillment; Attributes: [#oauth2.throwOnError(#oauth2.hasScope('write'))]
19:47:41.264 [https-jsse-nio-9443-exec-6] DEBUG o.s.s.w.a.i.FilterSecurityInterceptor - Previously Authenticated: org.springframework.security.authentication.AnonymousAuthenticationToken@2629f42a: Principal: anonymousUser; Credentials: [PROTECTED]; Authenticated: true; Details: org.springframework.security.web.authentication.WebAuthenticationDetails@ffffc434: RemoteIpAddress: 35.184.134.60; SessionId: null; Granted Authorities: ROLE_ANONYMOUS
19:47:41.268 [https-jsse-nio-9443-exec-6] DEBUG o.s.s.w.a.ExceptionTranslationFilter - Access is denied (user is anonymous); redirecting to authentication entry point
org.springframework.security.access.AccessDeniedException: Insufficient scope for this resource
        at org.springframework.security.oauth2.provider.expression.OAuth2SecurityExpressionMethods.throwOnError(OAuth2SecurityExpressionMethods.java:72)
        at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
        at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
        at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
        at java.lang.reflect.Method.invoke(Method.java:497)
        at org.springframework.expression.spel.support.ReflectiveMethodExecutor.execute(ReflectiveMethodExecutor.java:120)
        at org.springframework.expression.spel.ast.MethodReference.getValueInternal(MethodReference.java:111)
        at org.springframework.expression.spel.ast.MethodReference.access$000(MethodReference.java:54)
        at org.springframework.expression.spel.ast.MethodReference$MethodValueRef.getValue(MethodReference.java:391)
        at org.springframework.expression.spel.ast.CompoundExpression.getValueInternal(CompoundExpression.java:89)
        at org.springframework.expression.spel.ast.SpelNodeImpl.getTypedValue(SpelNodeImpl.java:116)
        at org.springframework.expression.spel.standard.SpelExpression.getValue(SpelExpression.java:306)
        at org.springframework.security.access.expression.ExpressionUtils.evaluateAsBoolean(ExpressionUtils.java:26)
        at org.springframework.security.web.access.expression.WebExpressionVoter.vote(WebExpressionVoter.java:52)
        at org.springframework.security.web.access.expression.WebExpressionVoter.vote(WebExpressionVoter.java:33)
        at org.springframework.security.access.vote.AffirmativeBased.decide(AffirmativeBased.java:63)
        at org.springframework.security.access.intercept.AbstractSecurityInterceptor.beforeInvocation(AbstractSecurityInterceptor.java:233)
        at org.springframework.security.web.access.intercept.FilterSecurityInterceptor.invoke(FilterSecurityInterceptor.java:124)
        at org.springframework.security.web.access.intercept.FilterSecurityInterceptor.doFilter(FilterSecurityInterceptor.java:91)
        at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:334)
        at org.springframework.security.web.access.ExceptionTranslationFilter.doFilter(ExceptionTranslationFilter.java:119)
        at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:334)
        at org.springframework.security.web.session.SessionManagementFilter.doFilter(SessionManagementFilter.java:137)
        at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:334)
        at org.springframework.security.web.authentication.AnonymousAuthenticationFilter.doFilter(AnonymousAuthenticationFilter.java:111)
        at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:334)
        at org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter.doFilter(SecurityContextHolderAwareRequestFilter.java:170)
        at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:334)
        at org.springframework.security.web.savedrequest.RequestCacheAwareFilter.doFilter(RequestCacheAwareFilter.java:63)
        at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:334)
        at org.springframework.security.oauth2.provider.authentication.OAuth2AuthenticationProcessingFilter.doFilter(OAuth2AuthenticationProcessingFilter.java:176)
        at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:334)
        at org.springframework.security.web.authentication.logout.LogoutFilter.doFilter(LogoutFilter.java:116)
        at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:334)
        at org.springframework.security.web.header.HeaderWriterFilter.doFilterInternal(HeaderWriterFilter.java:66)
        at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:107)
        at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:334)
        at org.springframework.security.web.context.SecurityContextPersistenceFilter.doFilter(SecurityContextPersistenceFilter.java:105)
        at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:334)
        at org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter.doFilterInternal(WebAsyncManagerIntegrationFilter.java:56)
        at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:107)
        at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:334)
        at org.springframework.security.web.FilterChainProxy.doFilterInternal(FilterChainProxy.java:215)
        at org.springframework.security.web.FilterChainProxy.doFilter(FilterChainProxy.java:178)
        at org.springframework.security.web.debug.DebugFilter.invokeWithWrappedRequest(DebugFilter.java:90)
        at org.springframework.security.web.debug.DebugFilter.doFilter(DebugFilter.java:77)
        at org.springframework.web.filter.DelegatingFilterProxy.invokeDelegate(DelegatingFilterProxy.java:357)
        at org.springframework.web.filter.DelegatingFilterProxy.doFilter(DelegatingFilterProxy.java:270)
        at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193)
        at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)
        at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:99)
        at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:107)
        at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193)
        at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)
        at org.springframework.web.filter.HttpPutFormContentFilter.doFilterInternal(HttpPutFormContentFilter.java:109)
        at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:107)
        at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193)
        at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)
        at org.springframework.web.filter.HiddenHttpMethodFilter.doFilterInternal(HiddenHttpMethodFilter.java:93)
        at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:107)
        at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193)
        at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)
        at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:200)
        at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:107)
        at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193)
        at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)
        at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:198)
        at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:96)
        at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:613)
        at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:140)
        at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:81)
        at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:87)
        at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:342)
        at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:803)
        at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:66)
        at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:790)
        at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1468)
        at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:49)
        at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1142)
        at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617)
        at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61)
        at java.lang.Thread.run(Thread.java:745)
Caused by: org.springframework.security.oauth2.common.exceptions.InsufficientScopeException: Insufficient scope for this resource
        at org.springframework.security.oauth2.provider.expression.OAuth2SecurityExpressionMethods.throwOnError(OAuth2SecurityExpressionMethods.java:71)
        ... 81 common frames omitted
19:47:41.271 [https-jsse-nio-9443-exec-6] DEBUG o.s.s.w.a.ExceptionTranslationFilter - Calling Authentication entry point.
19:47:41.275 [https-jsse-nio-9443-exec-6] DEBUG o.s.s.o.p.e.DefaultOAuth2ExceptionRenderer - Written [error="unauthorized", error_description="Full authentication is required to access this resource"] as "application/json;charset=UTF-8" using [org.springframework.http.converter.json.MappingJackson2HttpMessageConverter@1ff3a97]
19:47:41.276 [https-jsse-nio-9443-exec-6] DEBUG o.s.s.w.c.SecurityContextPersistenceFilter - SecurityContextHolder now cleared, as request processing completed

I tested on "OAuth 2.0 Playground" (https://developers.google.com/oauthplayground), and it works perfectly!

Is this due to the fact that my App hasn't been released and I'm still in test mode? Anybody got any ideas?

  • If you're doing a smart home action like turning on a TV, I'd suggest you take a look at directly integrating smart home: http://developers.google.com/smarthome – Nick Felker Aug 20 '18 at 19:49
  • I looked at smart home and I saw that it was necessary to activate Traits. But I also want to be able to change the channel and I haven't seen any Traits to do that. I don't know if that's problematic, but I didn't choose that. Do you think it's a problem or is it possible to do that? – Nicolas Dos Santos Aug 21 '18 at 07:41
  • Yeah, I suppose right now that's not going to work, but better TV support is something that was announced at I/O, so keep this in the back of your head. – Nick Felker Aug 21 '18 at 17:36

2 Answers2

1

This is because Google isn't sending the bearer token in the Authorization header (for various reasons, but at least partially because some services are using this to authorize the service - not the user of the service). It sends it as part of the JSON body.

If you are using the Action SDK, you'll find this in user.accessToken. In Dialogflow, this will be under originalDetectIntentRequest.payload.user.accessToken.

Prisoner
  • 49,922
  • 7
  • 53
  • 105
  • Thank you for your answer. I understand better why it doesn't work. However, in the documentation, [link](https://developers.google.com/actions/identity/oauth2-code-flow), the following is written in the "Handle data access requests" section: ... Google might make the following request to your service: GET /mycontent HTTP/1.1 Host: myservice.example.com Content-Type: application/x-www-form-urlencoded Authorization: Bearer ACCESS_TOKEN Maybe it's only for Google Smart Home? – Nicolas Dos Santos Aug 21 '18 at 07:40
  • Yeah, that is generic documentation. Other sections talk about how the data is sent specifically. There is an open bug to fix that. – Prisoner Aug 22 '18 at 14:05
0

Following Prisoner's answer, I created a custom org.springframework.security.oauth2.provider.authentication.TokenExtractor to manage the access-token present in the request body.

Here is the Kotlin code:

class BodyTokenExtractor : BearerTokenExtractor() {

    private val logger = LogFactory.getLog(BodyTokenExtractor::class.java)

    override fun extractToken(request: HttpServletRequest): String? {
        var token: String? = null

        if (HttpMethod.POST.matches(request.method)) {
            token = extractBodyToken(request)
        }

        if (token == null) {
            logger.debug("Token not found in body. Trying request headers.")
            token = super.extractToken(request)
        } else {
            request.setAttribute(OAuth2AuthenticationDetails.ACCESS_TOKEN_TYPE, OAuth2AccessToken.BEARER_TYPE)
        }

        return token
    }

    /**
     * Extract the OAuth token from the request body.
     *
     * @param request The request.
     * @return The token, or null if no OAuth authorization header was supplied.
     */
    protected fun extractBodyToken(request: HttpServletRequest): String? {
        try {
            val requestBody = IOUtils.toString(request.reader)
            val request = JacksonFactory().fromString(requestBody, Map::class.java)
            val originalDetectIntentRequest = request["originalDetectIntentRequest"] as Map<String, Object>
            if (originalDetectIntentRequest != null) {
                val payload = originalDetectIntentRequest["payload"] as Map<String, Object>
                if (payload != null) {
                    val user = payload["user"] as Map<String, Object>
                    if (user != null) {
                        return user["accessToken"] as String?
                    }
                }
            }
        } catch (e: IOException) {
            logger.debug("An error occurred while reading the request body: " + e.message, e)
        }
        return null
    }

}

This class is then called in the Resource Server :

@Configuration
@EnableResourceServer
class OAuthResourceServerConfig() : ResourceServerConfigurerAdapter() {
    ...
    @Throws(Exception::class)
    override fun configure(resources: ResourceServerSecurityConfigurer) {
        resources.resourceId(resourceId).tokenStore(tokenStore()).tokenExtractor(tokenExtractor())
    }

    private fun tokenStore(): TokenStore {
        return JdbcTokenStore(dataSource)
    }

    private fun tokenExtractor(): TokenExtractor {
        return BodyTokenExtractor()
    }
   ...
}

It is important to define a custom filter to allow to read multiple times the request body :

@Bean
fun multiReadFilter(): FilterRegistrationBean<*> {
    val registrationBean = FilterRegistrationBean<CachedRequestWrapperFilter>()
    val multiReadRequestFilter = CachedRequestWrapperFilter()
    registrationBean.filter = multiReadRequestFilter
    registrationBean.order = SecurityProperties.DEFAULT_FILTER_ORDER - 2
    registrationBean.urlPatterns = Arrays.asList("/api/*")
    return registrationBean
}

class CachedRequestWrapperFilter : Filter {
    @Throws(ServletException::class)
    override fun init(config: FilterConfig) {
        // nothing goes here
    }

    @Throws(java.io.IOException::class, ServletException::class)
    override fun doFilter(request: ServletRequest, response: ServletResponse, chain: FilterChain) {
        val requestWrapper = CachedHttpServletRequest(request as HttpServletRequest)
        // Pass request back down the filter chain
        chain.doFilter(requestWrapper, response)
    }

    override fun destroy() {
        /* Called before the Filter instance is removed from service by the web container*/
    }
}

public class CachedHttpServletRequestextends HttpServletRequestWrapper {
    private ByteArrayOutputStream cachedContent;

    public CachedHttpServletRequest(HttpServletRequest request) throws IOException {
        // Read the request body and populate the cachedContent
    }

    @Override
    public ServletInputStream getInputStream() throws IOException {
        // Create input stream from cachedContent
        // and return it
    }

    @Override
    public BufferedReader getReader() throws IOException {
        // Create a reader from cachedContent
        // and return it
    }
}

There are plenty of examples explaining how to wrap the request and read multiple times. Example: http://www.myjavarecipes.com/tag/how-to-read-request-twice/

By doing that, it works perfectly!