openanalytics / containerproxy

Manage HTTP proxy routes into Docker containers
Apache License 2.0
45 stars 66 forks source link

Support for OAuth2 refresh token #47

Closed nickmelis closed 2 years ago

nickmelis commented 3 years ago

I'm running ShinyProxy on Kubernetes, integrated with a OAuth2/OpenID authorization server. On login, the OIDC token is received by ShinyProxy and correctly passed down to the container as SHINYPROXY_OIDC_ACCESS_TOKEN environment variable. The Shiny app can then use the token to make API calls to a resource server, as specified in the documentation.

I was wondering, what happens when the token. expires? My tokens usually have short life (no more than 30 minutes). After that time, all API calls made by the Shiny app will start to fail. Does ShinyProxy support refreshing the token inside the container/pod?

LEDfan commented 3 years ago

Hi @nickmelis

ShinyProxy will not update the SHINYPROXY_OIDC_ACCESS_TOKEN env variable after it has expired. I don't think there is anyway to update the env variable after the app has started. However, we have plans (and already some code) to make the refresh token available to the app container. Unfortunately, this will not be part of the next release, as we have some other priorities at the moment.

django-djack commented 3 years ago

Hi @LEDfan , Hi @nickmelis

First of all, thanks @nickmelis for raising this issue. We also have the same problem.

@LEDfan, Do you know when there will be a SHINYPROXY_OIDC_REFRESH_TOKEN available alongside SHINYPROXY_OIDC_ACCESS_TOKEN ?

Best regards, Django.

LEDfan commented 3 years ago

Hi @django-datama

We currently don't have an ETA for the next release. Unfortunately, there is also not really a work-around for it. However, I can try to release a snapshot release next week (Docker image + JAR) and then you could use that version (with the warning that it is still a development version)

django-djack commented 3 years ago

Dear @LEDfan

A snapshot release would be great ! We really need this feature available... Sincerely,

LEDfan commented 3 years ago

The snapshot is ready. You can download the jar here or use the Docker image: openanalytics/shinyproxy-snapshot:2.5.1-SNAPSHOT-20210519.072151

Here is an example on how to use with OIDC:

  - id: 01_hello
    display-name: Hello Application
    description: Application which demonstrates the basics of a Shiny app
    container-cmd: [ "R", "-e", "shinyproxy::run_01_hello()" ]
    container-image: openanalytics/shinyproxy-demo
    container-env:
      SUB: "#{oidcUser.attributes['sub']}"
      EMAIL: "#{oidcUser.attributes['email']}"
      REFRESH_TOKEN: "#{oidcUser.refreshToken}"
      ID_TOKEN: "#{oidcUser.idToken.tokenValue}"

I'm looking forward to any feedback you have!

django-djack commented 3 years ago

Awesome !! Thanks a lot !

django-djack commented 3 years ago

I just implemented the setup on staging, and the container crashes...

Stack's TL;DR :

Caused by: org.springframework.expression.spel.SpelEvaluationException: EL1008E: Property or field 'oidcUser' cannot be found on object of type 'eu.openanalytics.containerproxy.spec.expression.SpecExpressionContext' - maybe not public or not valid?

There is a stack of the error message

Error
Status code: 500

Message: Failed to start container

Stack Trace:
eu.openanalytics.containerproxy.ContainerProxyException: Failed to start container
at eu.openanalytics.containerproxy.backend.AbstractContainerBackend.startProxy(AbstractContainerBackend.java:118)
at eu.openanalytics.containerproxy.service.ProxyService.startProxy(ProxyService.java:222)
at eu.openanalytics.shinyproxy.controllers.AppController.getOrStart(AppController.java:107)
at eu.openanalytics.shinyproxy.controllers.AppController.startApp(AppController.java:64)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.base/java.lang.reflect.Method.invoke(Method.java:566)
at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:190)
at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:138)
at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:105)
at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:878)
at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:792)
at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87)
at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1040)
at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:943)
at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1006)
at org.springframework.web.servlet.FrameworkServlet.doPost(FrameworkServlet.java:909)
at javax.servlet.http.HttpServlet.service(HttpServlet.java:517)
at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:883)
at javax.servlet.http.HttpServlet.service(HttpServlet.java:584)
at io.undertow.servlet.handlers.ServletHandler.handleRequest(ServletHandler.java:74)
at io.undertow.servlet.handlers.FilterHandler$FilterChainImpl.doFilter(FilterHandler.java:129)
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:320)
at org.springframework.security.web.access.intercept.FilterSecurityInterceptor.invoke(FilterSecurityInterceptor.java:126)
at org.springframework.security.web.access.intercept.FilterSecurityInterceptor.doFilter(FilterSecurityInterceptor.java:90)
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:334)
at org.springframework.security.web.access.ExceptionTranslationFilter.doFilter(ExceptionTranslationFilter.java:118)
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:158)
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.web.authentication.www.BasicAuthenticationFilter.doFilterInternal(BasicAuthenticationFilter.java:155)
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119)
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:334)
at org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter.doFilter(AbstractAuthenticationProcessingFilter.java:200)
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:334)
at org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter.doFilter(AbstractAuthenticationProcessingFilter.java:200)
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:334)
at org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter.doFilterInternal(OAuth2AuthorizationRequestRedirectFilter.java:160)
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119)
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.csrf.CsrfFilter.doFilterInternal(CsrfFilter.java:117)
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119)
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:334)
at org.springframework.security.web.header.HeaderWriterFilter.doHeadersAfter(HeaderWriterFilter.java:92)
at org.springframework.security.web.header.HeaderWriterFilter.doFilterInternal(HeaderWriterFilter.java:77)
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119)
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:119)
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.web.filter.DelegatingFilterProxy.invokeDelegate(DelegatingFilterProxy.java:358)
at org.springframework.web.filter.DelegatingFilterProxy.doFilter(DelegatingFilterProxy.java:271)
at io.undertow.servlet.core.ManagedFilter.doFilter(ManagedFilter.java:61)
at io.undertow.servlet.handlers.FilterHandler$FilterChainImpl.doFilter(FilterHandler.java:131)
at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100)
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119)
at io.undertow.servlet.core.ManagedFilter.doFilter(ManagedFilter.java:61)
at io.undertow.servlet.handlers.FilterHandler$FilterChainImpl.doFilter(FilterHandler.java:131)
at org.springframework.boot.actuate.metrics.web.servlet.WebMvcMetricsFilter.doFilterInternal(WebMvcMetricsFilter.java:93)
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119)
at io.undertow.servlet.core.ManagedFilter.doFilter(ManagedFilter.java:61)
at io.undertow.servlet.handlers.FilterHandler$FilterChainImpl.doFilter(FilterHandler.java:131)
at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201)
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119)
at io.undertow.servlet.core.ManagedFilter.doFilter(ManagedFilter.java:61)
at io.undertow.servlet.handlers.FilterHandler$FilterChainImpl.doFilter(FilterHandler.java:131)
at io.undertow.servlet.handlers.FilterHandler.handleRequest(FilterHandler.java:84)
at io.undertow.servlet.handlers.security.ServletSecurityRoleHandler.handleRequest(ServletSecurityRoleHandler.java:62)
at io.undertow.servlet.handlers.ServletChain$1.handleRequest(ServletChain.java:68)
at io.undertow.servlet.handlers.ServletDispatchingHandler.handleRequest(ServletDispatchingHandler.java:36)
at io.undertow.server.handlers.PathHandler.handleRequest(PathHandler.java:91)
at eu.openanalytics.containerproxy.util.ProxyMappingManager$ProxyPathHandler.handleRequest(ProxyMappingManager.java:160)
at io.undertow.servlet.handlers.RedirectDirHandler.handleRequest(RedirectDirHandler.java:68)
at io.undertow.servlet.handlers.security.SSLInformationAssociationHandler.handleRequest(SSLInformationAssociationHandler.java:132)
at io.undertow.servlet.handlers.security.ServletAuthenticationCallHandler.handleRequest(ServletAuthenticationCallHandler.java:57)
at io.undertow.server.handlers.PredicateHandler.handleRequest(PredicateHandler.java:43)
at io.undertow.security.handlers.AbstractConfidentialityHandler.handleRequest(AbstractConfidentialityHandler.java:46)
at io.undertow.servlet.handlers.security.ServletConfidentialityConstraintHandler.handleRequest(ServletConfidentialityConstraintHandler.java:64)
at io.undertow.security.handlers.AuthenticationMechanismsHandler.handleRequest(AuthenticationMechanismsHandler.java:60)
at io.undertow.servlet.handlers.security.CachedAuthenticatedSessionHandler.handleRequest(CachedAuthenticatedSessionHandler.java:77)
at io.undertow.security.handlers.AbstractSecurityContextAssociationHandler.handleRequest(AbstractSecurityContextAssociationHandler.java:43)
at io.undertow.server.handlers.PredicateHandler.handleRequest(PredicateHandler.java:43)
at io.undertow.server.handlers.PredicateHandler.handleRequest(PredicateHandler.java:43)
at io.undertow.servlet.handlers.ServletInitialHandler.handleFirstRequest(ServletInitialHandler.java:269)
at io.undertow.servlet.handlers.ServletInitialHandler.access$100(ServletInitialHandler.java:78)
at io.undertow.servlet.handlers.ServletInitialHandler$2.call(ServletInitialHandler.java:133)
at io.undertow.servlet.handlers.ServletInitialHandler$2.call(ServletInitialHandler.java:130)
at io.undertow.servlet.core.ServletRequestContextThreadSetupAction$1.call(ServletRequestContextThreadSetupAction.java:48)
at io.undertow.servlet.core.ContextClassLoaderSetupAction$1.call(ContextClassLoaderSetupAction.java:43)
at io.undertow.servlet.handlers.ServletInitialHandler.dispatchRequest(ServletInitialHandler.java:249)
at io.undertow.servlet.handlers.ServletInitialHandler.access$000(ServletInitialHandler.java:78)
at io.undertow.servlet.handlers.ServletInitialHandler$1.handleRequest(ServletInitialHandler.java:99)
at io.undertow.server.Connectors.executeRootHandler(Connectors.java:370)
at io.undertow.server.HttpServerExchange$1.run(HttpServerExchange.java:836)
at org.jboss.threads.ContextClassLoaderSavingRunnable.run(ContextClassLoaderSavingRunnable.java:35)
at org.jboss.threads.EnhancedQueueExecutor.safeRun(EnhancedQueueExecutor.java:2019)
at org.jboss.threads.EnhancedQueueExecutor$ThreadBody.doRunTask(EnhancedQueueExecutor.java:1558)
at org.jboss.threads.EnhancedQueueExecutor$ThreadBody.run(EnhancedQueueExecutor.java:1449)
at java.base/java.lang.Thread.run(Thread.java:834)
Caused by: org.springframework.expression.spel.SpelEvaluationException: EL1008E: Property or field 'oidcUser' cannot be found on object of type 'eu.openanalytics.containerproxy.spec.expression.SpecExpressionContext' - maybe not public or not valid?
at org.springframework.expression.spel.ast.PropertyOrFieldReference.readProperty(PropertyOrFieldReference.java:217)
at org.springframework.expression.spel.ast.PropertyOrFieldReference.getValueInternal(PropertyOrFieldReference.java:104)
at org.springframework.expression.spel.ast.PropertyOrFieldReference.getValueInternal(PropertyOrFieldReference.java:91)
at org.springframework.expression.spel.ast.CompoundExpression.getValueRef(CompoundExpression.java:55)
at org.springframework.expression.spel.ast.CompoundExpression.getValueInternal(CompoundExpression.java:91)
at org.springframework.expression.spel.ast.SpelNodeImpl.getValue(SpelNodeImpl.java:112)
at org.springframework.expression.spel.standard.SpelExpression.getValue(SpelExpression.java:272)
at eu.openanalytics.containerproxy.spec.expression.SpecExpressionResolver.evaluate(SpecExpressionResolver.java:102)
at eu.openanalytics.containerproxy.spec.expression.SpecExpressionResolver.evaluateToString(SpecExpressionResolver.java:106)
at eu.openanalytics.containerproxy.spec.expression.ExpressionAwareContainerSpec.resolve(ExpressionAwareContainerSpec.java:111)
at eu.openanalytics.containerproxy.spec.expression.ExpressionAwareContainerSpec.lambda$getEnv$0(ExpressionAwareContainerSpec.java:59)
at java.base/java.util.HashMap$EntrySpliterator.forEachRemaining(HashMap.java:1746)
at java.base/java.util.stream.ReferencePipeline$Head.forEach(ReferencePipeline.java:658)
at eu.openanalytics.containerproxy.spec.expression.ExpressionAwareContainerSpec.getEnv(ExpressionAwareContainerSpec.java:59)
at eu.openanalytics.containerproxy.backend.AbstractContainerBackend.buildEnv(AbstractContainerBackend.java:225)
at eu.openanalytics.containerproxy.backend.docker.DockerEngineBackend.startContainer(DockerEngineBackend.java:82)
at eu.openanalytics.containerproxy.backend.AbstractContainerBackend.doStartProxy(AbstractContainerBackend.java:140)
at eu.openanalytics.containerproxy.backend.AbstractContainerBackend.startProxy(AbstractContainerBackend.java:115)
... 111 more
LEDfan commented 3 years ago

Hi @django-datama

I just tested the container and JAR file again and everything works properly here. From the error you get I think you are not running the correct (snapshot) version of ShinyProxy).

Can you check that you are running the version I listed above? On startup ShinyProxy should output

INFO 26706 --- [           main] e.o.c.util.StartupEventListener          : Started ShinyProxy 2.5.1-SNAPSHOT (ContainerProxy 0.9.0-SNAPSHOT)
django-djack commented 3 years ago

Hi,

Yes, indeed! You were right @LEDfan ! I was still using the docker cache!

It works now, thanks again.

LEDfan commented 2 years ago

This is now part of ShinyProxy 2.6.0 (ContainerProxy 0.8.10).

See the new page on the documentation: https://shinyproxy.io/documentation/spel/#authentication-objects

Thank you for the suggestion!

django-djack commented 2 years ago

Awesome, thank you @LEDfan and the team !

django-djack commented 2 years ago

Hello @LEDfan,

I wanted to know if there is a way to know if the access_token is refreshed and if so, at what frequency ? Also, is the env variable updated ?

There are no mentions of this in the doc

LEDfan commented 2 years ago

Hi @django-datama

I'm afraid there is a misunderstanding here. The feature request here makes it possible to expose the OIDC refresh token to a container started by ShinyProxy. ShinyProxy will not update the access token attached to a container. The reason is mostly that it is impossible/hard to update env variables.

The idea is thus that you expose the refresh token to the container (see docs: https://shinyproxy.io/documentation/spel/#authentication-objects) and that you refresh the access token in your own code. Here is some example code to do this with Python and Flask:

import requests
import os
import jwt

from flask import Flask, render_template

app = Flask(__name__,
            template_folder='/opt/app/templates',
            static_folder='/opt/app/static')

if "SHINYPROXY_REFRESH_TOKEN" in os.environ:
    refresh_token = os.environ["SHINYPROXY_REFRESH_TOKEN"]
else:
    refresh_token = None

if "AUTH_URL" in os.environ:
    auth_url = os.environ["AUTH_URL"]
else:
    auth_url = None

@app.route('/')
def home():
    if refresh_token is None or auth_url is None:
        return "No refresh or auth url configured"

    payload = {
        "grant_type": "refresh_token",
        "refresh_token": refresh_token,
        "client_id": "<client_id>",
        "client_secret": "<client_secret>"
    }

    headers = {
        'Content-Type': 'application/x-www-form-urlencoded'
    }

    response = requests.request("POST", auth_url, headers=headers, data=payload)
    access_token = response.json()["access_token"]
    decoded_token = jwt.decode(access_token, options={"verify_signature": False})
    return render_template('home.html', access_token=access_token, decoded_token=decoded_token)

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=int("8080"), debug=False)

You can package this into a Docker container and run it as a ShinyProxy app. Every time you refresh the page, the app will fetch a new access token.