openanalytics / shinyproxy

ShinyProxy - Open Source Enterprise Deployment for Shiny and data science apps
https://www.shinyproxy.io
Apache License 2.0
525 stars 151 forks source link

OIDC Refresh Token #464

Closed nik-humphries closed 6 months ago

nik-humphries commented 1 year ago

I see this has been mentioned here https://github.com/openanalytics/shinyproxy/issues/365 and also on the containerproxy repo https://github.com/openanalytics/containerproxy/issues/47 and here https://github.com/openanalytics/containerproxy/issues/65 (still open) and is in the documentation here https://shinyproxy.io/documentation/spel/#openid-connect

I am using Azure B2C for authentication and cannot seem to access the refreshtoken.

#{oidcUser.attributes.xxx} works fine and so does #{oidcUser.idToken.tokenValue} but #{oidcUser.refreshToken} returns NULL.

Is there anything obvious that I could be missing? Using the same policies elsewhere returns a refresh token (in browser msal auth).

image

    container-env:
      OIDC_ID_TOKEN: "#{oidcUser.idToken.tokenValue}"
      OIDC_ALL: "#{oidcUser}"
      OIDC_REFRESH_TOKEN: "#{oidcUser.refreshToken}"
      IS_AZURE: true
      AZ_CONTAINERNAME: xxx
      AZ_MSI_CLIENT_ID: xxx
      POSTGRESQL_DRIVER: "PostgreSQL ANSI"
      POSTGRESQL_DB_NAME: "xxx"
      POSTGRESQL_USER: "xxx"
      POSTGRESQL_USER_APPEND: FALSE
      # RSTUDIO
      DISABLE_AUTH: true
      USER: "#{proxy.userId}"
      # Use the following line when using ShinyProxy 2.6.0 or later
      WWW_ROOT_PATH: "#{proxy.getRuntimeValue('SHINYPROXY_PUBLIC_PATH')}"
      # Use the following line when using ShinyProxy 2.5.0
      # WWW_ROOT_PATH: "#{proxySpec.containerSpecs[0].env.get('SHINYPROXY_PUBLIC_PATH')}"
nik-humphries commented 1 year ago

I've been having a play around myself to see if I can find a refresh token anywhere. I will start with I'm not a Java dev. Putting a breakpoint on line 114 of the decompiled OpenIDAuthenticationBackend.class and observing the user and client objects show that there's no refreshToken at this stage.

image

image

I'm not entirely sure how all the class construction stuff happens in Java, but I also noticed that there is no reference to a refreshToken or the method getRefreshToken in classes auth.impl/OpenIDAuthenticationBackend or spec.expression/SpecExpressionContext and getRefreshToken is referenced in the first and the class it's referenced in is referenced in the second. No idea if that has anything to do with anything though.

OpenIDAuthenticationBackend Class ```java // Source code is decompiled from a .class file using FernFlower decompiler. package eu.openanalytics.containerproxy.auth.impl; import eu.openanalytics.containerproxy.auth.IAuthenticationBackend; import eu.openanalytics.containerproxy.auth.impl.oidc.OpenIdReAuthorizeFilter; import eu.openanalytics.containerproxy.spec.expression.SpecExpressionContext; import eu.openanalytics.containerproxy.spec.expression.SpecExpressionResolver; import eu.openanalytics.containerproxy.util.ContextPathHelper; import java.util.ArrayList; import java.util.Collection; import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; import javax.inject.Inject; import net.minidev.json.parser.JSONParser; import net.minidev.json.parser.ParseException; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Lazy; import org.springframework.core.env.Environment; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.ExpressionUrlAuthorizationConfigurer; import org.springframework.security.config.annotation.web.configurers.oauth2.client.OAuth2LoginConfigurer; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper; import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver; import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestCustomizers; import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver; import org.springframework.security.oauth2.core.oidc.OidcIdToken; import org.springframework.security.oauth2.core.oidc.user.OidcUserAuthority; import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.logout.LogoutSuccessHandler; import org.springframework.security.web.authentication.logout.SimpleUrlLogoutSuccessHandler; public class OpenIDAuthenticationBackend implements IAuthenticationBackend { public static final String NAME = "openid"; private static final String ENV_TOKEN_NAME = "SHINYPROXY_OIDC_ACCESS_TOKEN"; private final Logger log = LogManager.getLogger(OpenIDAuthenticationBackend.class); @Inject private Environment environment; @Inject private ClientRegistrationRepository clientRegistrationRepo; @Inject @Lazy private SavedRequestAwareAuthenticationSuccessHandler successHandler; private static OAuth2AuthorizedClientService oAuth2AuthorizedClientService; @Inject private OpenIdReAuthorizeFilter openIdReAuthorizeFilter; @Inject private SpecExpressionResolver specExpressionResolver; public OpenIDAuthenticationBackend() { } @Autowired public void setOAuth2AuthorizedClientService(OAuth2AuthorizedClientService oAuth2AuthorizedClientService) { OpenIDAuthenticationBackend.oAuth2AuthorizedClientService = oAuth2AuthorizedClientService; } public String getName() { return "openid"; } public boolean hasAuthorization() { return true; } public void configureHttpSecurity(HttpSecurity http, ExpressionUrlAuthorizationConfigurer.AuthorizedUrl anyRequestConfigurer) throws Exception { anyRequestConfigurer.authenticated(); ((HttpSecurity)((OAuth2LoginConfigurer)((OAuth2LoginConfigurer)http.oauth2Login().loginPage("/login").successHandler(this.successHandler)).clientRegistrationRepository(this.clientRegistrationRepo).authorizedClientService(oAuth2AuthorizedClientService).authorizationEndpoint().authorizationRequestResolver(this.authorizationRequestResolver()).and().failureHandler(new 1(this))).userInfoEndpoint().userAuthoritiesMapper(this.createAuthoritiesMapper()).oidcUserService(this.createOidcUserService()).and().and()).addFilterAfter(this.openIdReAuthorizeFilter, UsernamePasswordAuthenticationFilter.class); } private OAuth2AuthorizationRequestResolver authorizationRequestResolver() { Boolean usePkce = (Boolean)this.environment.getProperty("proxy.openid.with-pkce", Boolean.class, false); DefaultOAuth2AuthorizationRequestResolver authorizationRequestResolver = new DefaultOAuth2AuthorizationRequestResolver(this.clientRegistrationRepo, "/oauth2/authorization"); if (usePkce) { authorizationRequestResolver.setAuthorizationRequestCustomizer(OAuth2AuthorizationRequestCustomizers.withPkce()); } return authorizationRequestResolver; } public void configureAuthenticationManagerBuilder(AuthenticationManagerBuilder auth) throws Exception { } public String getLoginRedirectURI() { return ContextPathHelper.withoutEndingSlash() + "/oauth2/authorization" + "/" + "shinyproxy"; } public String getLogoutSuccessURL() { String logoutURL = this.environment.getProperty("proxy.openid.logout-url"); if (logoutURL == null || logoutURL.trim().isEmpty()) { logoutURL = super.getLogoutSuccessURL(); } return logoutURL; } public void customizeContainerEnv(Authentication user, Map env) { OAuth2AuthorizedClient client = refreshClient(user.getName()); if (client != null && client.getAccessToken() != null) { env.put("SHINYPROXY_OIDC_ACCESS_TOKEN", client.getAccessToken().getTokenValue()); } } public LogoutSuccessHandler getLogoutSuccessHandler() { return (httpServletRequest, httpServletResponse, authentication) -> { String resolvedLogoutUrl; if (authentication != null) { SpecExpressionContext context = SpecExpressionContext.create(new Object[]{authentication.getPrincipal(), authentication.getCredentials()}); resolvedLogoutUrl = this.specExpressionResolver.evaluateToString(this.getLogoutSuccessURL(), context); } else { resolvedLogoutUrl = this.getLogoutSuccessURL(); } SimpleUrlLogoutSuccessHandler delegate = new SimpleUrlLogoutSuccessHandler(); delegate.setDefaultTargetUrl(resolvedLogoutUrl); delegate.onLogoutSuccess(httpServletRequest, httpServletResponse, authentication); }; } protected GrantedAuthoritiesMapper createAuthoritiesMapper() { String rolesClaimName = this.environment.getProperty("proxy.openid.roles-claim"); return rolesClaimName != null && !rolesClaimName.isEmpty() ? (authorities) -> { Set mappedAuthorities = new HashSet(); Iterator var4 = authorities.iterator(); while(true) { GrantedAuthority auth; do { if (!var4.hasNext()) { return mappedAuthorities; } auth = (GrantedAuthority)var4.next(); } while(!(auth instanceof OidcUserAuthority)); OidcIdToken idToken = ((OidcUserAuthority)auth).getIdToken(); if (this.log.isDebugEnabled()) { String lineSep = System.getProperty("line.separator"); String claims = (String)idToken.getClaims().entrySet().stream().map((e) -> { return String.format("%s -> %s", e.getKey(), e.getValue()); }).collect(Collectors.joining(lineSep)); this.log.debug(String.format("Checking for roles in claim '%s'. Available claims in ID token (%d):%s%s", rolesClaimName, idToken.getClaims().size(), lineSep, claims)); } Object claimValue = idToken.getClaims().get(rolesClaimName); Iterator var12 = parseRolesClaim(this.log, rolesClaimName, claimValue).iterator(); while(var12.hasNext()) { String role = (String)var12.next(); String mappedRole = role.toUpperCase().startsWith("ROLE_") ? role : "ROLE_" + role; mappedAuthorities.add(new SimpleGrantedAuthority(mappedRole.toUpperCase())); } } } : (authorities) -> { return authorities; }; } public static List parseRolesClaim(Logger log, String rolesClaimName, Object claimValue) { if (claimValue == null) { log.debug(String.format("No roles claim with name %s found", rolesClaimName)); return new ArrayList(); } else { log.debug(String.format("Matching claim found: %s -> %s (%s)", rolesClaimName, claimValue, claimValue.getClass())); ArrayList result; if (claimValue instanceof Collection) { result = new ArrayList(); Iterator var7 = ((Collection)claimValue).iterator(); while(var7.hasNext()) { Object object = var7.next(); if (object != null) { result.add(object.toString()); } } log.debug(String.format("Parsed roles claim as Java Collection: %s -> %s (%s)", rolesClaimName, result, result.getClass())); return result; } else if (claimValue instanceof String) { result = new ArrayList(); try { Object value = (new JSONParser(-1)).parse((String)claimValue); if (value instanceof List) { List valueList = (List)value; valueList.forEach((o) -> { result.add(o.toString()); }); } } catch (ParseException var6) { log.debug(String.format("Unable to parse claim as JSON: %s -> %s (%s)", rolesClaimName, claimValue, claimValue.getClass())); } log.debug(String.format("Parsed roles claim as JSON: %s -> %s (%s)", rolesClaimName, result, result.getClass())); return result; } else { log.debug(String.format("No parser found for roles claim (unsupported type): %s -> %s (%s)", rolesClaimName, claimValue, claimValue.getClass())); return new ArrayList(); } } } protected OidcUserService createOidcUserService() { return new 2(this); } private static OAuth2AuthorizedClient refreshClient(String principalName) { return oAuth2AuthorizedClientService.loadAuthorizedClient("shinyproxy", principalName); } } ```
LEDfan commented 10 months ago

Hi @nik-humphries , it's great you mentioned you are using Azure B2C, this allowed me to test it with our test B2C instance. As it turns out when using Azure B2C, you need to add the offline_access scope in order to get a refresh token (see e.g. https://learn.microsoft.com/en-us/azure/active-directory-b2c/authorization-code-flow#2-get-an-access-token )

Can you try by adding this? E.g using:

proxy:
  openid:
    # ...
    scopes: ['offline_access', '..']
LEDfan commented 6 months ago

I think this issue has been solved, so I'll close it, please open a new issue or re-open this issue if you are still experiencing an issue.