In the previous blog post, we discussed how to configure a simple OAuth2 authentication. However, our implementation has a major flaw in it: we are using an in-memory token store.
In-Memory token stores should be used only during development or whether your application has a single server, as you can’t easily share them between nodes and, in case of a server restart, you will lose all access tokens in it.
Spring-security-oauth2 already has built-in support for JDBC and JWT. However, if you need to save your tokens somewhere else, you have to create your own spring security token store. Unfortunately, implementing such a thing is not a trivial task, and I hope the following recipe will save you a couple hours of work.
Let’s start by creating the two entities responsible for storing your access and refresh token, and their respective repositories:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
importar lombok.Datos; importar org.springframework.datos.anotación.Id; importar org.springframework.datos.couchbase.núcleo.cartografía.Documento; importar org.springframework.seguridad.oauth2.común.OAuth2AccessToken; importar org.springframework.seguridad.oauth2.proveedor.OAuth2Authentication; @Documento @Datos público clase CouchbaseAccessToken { @Id privado Cadena id; privado Cadena tokenId; privado OAuth2AccessToken ficha; privado Cadena authenticationId; privado Cadena nombre de usuario; privado Cadena clientId; privado Cadena autenticación; privado Cadena refreshToken; público OAuth2Authentication getAuthentication() { devolver SerializableObjectConverter.deserialize(autenticación); } público void setAuthentication(OAuth2Authentication autenticación) { este.autenticación = SerializableObjectConverter.serialize(autenticación); } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
@N1qlPrimaryIndexed @VerIndexado(designDoc = "couchbaseAccessToken") público interfaz CouchbaseAccessTokenRepository extiende CouchbasePagingAndSortingRepository<CouchbaseAccessToken, Cadena> { Lista<CouchbaseAccessToken> findByClientId(Cadena clientId); Lista<CouchbaseAccessToken> findByClientIdAndUsername(Cadena clientId, Cadena nombre de usuario); Opcional<CouchbaseAccessToken> findByTokenId(Cadena tokenId); Opcional<CouchbaseAccessToken> findByRefreshToken(Cadena refreshToken); Opcional<CouchbaseAccessToken> findByAuthenticationId(Cadena authenticationId); } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
importar lombok.Datos; importar org.springframework.datos.anotación.Id; importar org.springframework.datos.couchbase.núcleo.cartografía.Documento; importar org.springframework.seguridad.oauth2.común.OAuth2RefreshToken; importar org.springframework.seguridad.oauth2.proveedor.OAuth2Authentication; @Documento @Datos público clase CouchbaseRefreshToken { @Id privado Cadena id; privado Cadena tokenId; privado OAuth2RefreshToken ficha; privado Cadena autenticación; público OAuth2Authentication getAuthentication() { devolver SerializableObjectConverter.deserialize(autenticación); } público void setAuthentication(OAuth2Authentication autenticación) { este.autenticación = SerializableObjectConverter.serialize(autenticación); } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
importar org.springframework.datos.couchbase.núcleo.consulta.N1qlPrimaryIndexed; importar org.springframework.datos.couchbase.núcleo.consulta.VerIndexado; importar org.springframework.datos.couchbase.repositorio.CouchbasePagingAndSortingRepository; importar java.util.Lista; importar java.util.Opcional; @N1qlPrimaryIndexed @VerIndexado(designDoc = "couchbaseAccessToken") público interfaz CouchbaseAccessTokenRepository extiende CouchbasePagingAndSortingRepository<CouchbaseAccessToken, Cadena> { Lista<CouchbaseAccessToken> findByClientId(Cadena clientId); Lista<CouchbaseAccessToken> findByClientIdAndUsername(Cadena clientId, Cadena nombre de usuario); Opcional<CouchbaseAccessToken> findByTokenId(Cadena tokenId); Opcional<CouchbaseAccessToken> findByRefreshToken(Cadena refreshToken); Opcional<CouchbaseAccessToken> findByAuthenticationId(Cadena authenticationId); } |
Tenga en cuenta que OAuth2Authentication is an interface, so I have no option other than serializing the object to store it in the database. Here is the class responsible for serializing/deserializing it:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
público clase SerializableObjectConverter { público estático Cadena serialize(OAuth2Authentication objeto) { pruebe { byte[] bytes = SerializationUtils.serialize(objeto); devolver Base64.encodeBase64String(bytes); } captura(Excepción e) { e.printStackTrace(); tirar e; } } público estático OAuth2Authentication deserialize(Cadena encodedObject) { pruebe { byte[] bytes = Base64.decodeBase64(encodedObject); devolver (OAuth2Authentication) SerializationUtils.deserialize(bytes); } captura(Excepción e) { e.printStackTrace(); tirar e; } } |
Now, we can finally create our custom spring oauth2 token store. To do that, all we need is to implement the long list of methods of the org.springframework.security.oauth2.provider.token.TokenStore:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 |
importar org.springframework.seguridad.oauth2.común.OAuth2AccessToken; importar org.springframework.seguridad.oauth2.común.OAuth2RefreshToken; importar org.springframework.seguridad.oauth2.proveedor.OAuth2Authentication; importar org.springframework.seguridad.oauth2.proveedor.ficha.AuthenticationKeyGenerator; importar org.springframework.seguridad.oauth2.proveedor.ficha.DefaultAuthenticationKeyGenerator; importar org.springframework.seguridad.oauth2.proveedor.ficha.TokenStore; importar java.io.UnsupportedEncodingException; importar java.matemáticas.BigInteger; importar java.seguridad.MessageDigest; importar java.seguridad.NoSuchAlgorithmException; importar java.util.*; público clase CouchbaseTokenStore implementa TokenStore { privado CouchbaseAccessTokenRepository cbAccessTokenRepository; privado CouchbaseRefreshTokenRepository cbRefreshTokenRepository; público CouchbaseTokenStore(CouchbaseAccessTokenRepository cbAccessTokenRepository, CouchbaseRefreshTokenRepository cbRefreshTokenRepository){ este.cbAccessTokenRepository = cbAccessTokenRepository; este.cbRefreshTokenRepository = cbRefreshTokenRepository; } privado AuthenticationKeyGenerator authenticationKeyGenerator = nuevo DefaultAuthenticationKeyGenerator(); @Override público OAuth2Authentication readAuthentication(OAuth2AccessToken accessToken) { devolver readAuthentication(accessToken.getValue()); } @Override público OAuth2Authentication readAuthentication(Cadena ficha) { Opcional<CouchbaseAccessToken> accessToken = cbAccessTokenRepository.findByTokenId(extractTokenKey(ficha)); si (accessToken.isPresent()) { devolver accessToken.consiga().getAuthentication(); } devolver null; } @Override público void storeAccessToken(OAuth2AccessToken accessToken, OAuth2Authentication autenticación) { Cadena refreshToken = null; si (accessToken.getRefreshToken() != null) { refreshToken = accessToken.getRefreshToken().getValue(); } si (readAccessToken(accessToken.getValue()) != null) { este.removeAccessToken(accessToken); } CouchbaseAccessToken cat = nuevo CouchbaseAccessToken(); cat.setId(UUID.randomUUID().toString()+UUID.randomUUID().toString()); cat.setTokenId(extractTokenKey(accessToken.getValue())); cat.setToken(accessToken); cat.setAuthenticationId(authenticationKeyGenerator.extractKey(autenticación)); cat.setUsername(autenticación.isClientOnly() ? null : autenticación.getName()); cat.setClientId(autenticación.getOAuth2Request().getClientId()); cat.setAuthentication(autenticación); cat.setRefreshToken(extractTokenKey(refreshToken)); cbAccessTokenRepository.guardar(cat); } @Override público OAuth2AccessToken readAccessToken(Cadena tokenValue) { Opcional<CouchbaseAccessToken> accessToken = cbAccessTokenRepository.findByTokenId(extractTokenKey(tokenValue)); si (accessToken.isPresent()) { devolver accessToken.consiga().getToken(); } devolver null; } @Override público void removeAccessToken(OAuth2AccessToken oAuth2AccessToken) { Opcional<CouchbaseAccessToken> accessToken = cbAccessTokenRepository.findByTokenId(extractTokenKey(oAuth2AccessToken.getValue())); si (accessToken.isPresent()) { cbAccessTokenRepository.borrar(accessToken.consiga()); } } @Override público void storeRefreshToken(OAuth2RefreshToken refreshToken, OAuth2Authentication autenticación) { CouchbaseRefreshToken crt = nuevo CouchbaseRefreshToken(); crt.setId(UUID.randomUUID().toString()+UUID.randomUUID().toString()); crt.setTokenId(extractTokenKey(refreshToken.getValue())); crt.setToken(refreshToken); crt.setAuthentication(autenticación); cbRefreshTokenRepository.guardar(crt); } @Override público OAuth2RefreshToken readRefreshToken(Cadena tokenValue) { Opcional<CouchbaseRefreshToken> refreshToken = cbRefreshTokenRepository.findByTokenId(extractTokenKey(tokenValue)); devolver refreshToken.isPresent()? refreshToken.consiga().getToken() :null; } @Override público OAuth2Authentication readAuthenticationForRefreshToken(OAuth2RefreshToken refreshToken) { Opcional<CouchbaseRefreshToken> rtk = cbRefreshTokenRepository.findByTokenId(extractTokenKey(refreshToken.getValue())); devolver rtk.isPresent()? rtk.consiga().getAuthentication() :null; } @Override público void removeRefreshToken(OAuth2RefreshToken refreshToken) { Opcional<CouchbaseRefreshToken> rtk = cbRefreshTokenRepository.findByTokenId(extractTokenKey(refreshToken.getValue())); si (rtk.isPresent()) { cbRefreshTokenRepository.borrar(rtk.consiga()); } } @Override público void removeAccessTokenUsingRefreshToken(OAuth2RefreshToken refreshToken) { Opcional<CouchbaseAccessToken> ficha = cbAccessTokenRepository.findByRefreshToken(extractTokenKey(refreshToken.getValue())); si(ficha.isPresent()){ cbAccessTokenRepository.borrar(ficha.consiga()); } } @Override público OAuth2AccessToken getAccessToken(OAuth2Authentication autenticación) { OAuth2AccessToken accessToken = null; Cadena authenticationId = authenticationKeyGenerator.extractKey(autenticación); Opcional<CouchbaseAccessToken> ficha = cbAccessTokenRepository.findByAuthenticationId(authenticationId); si(ficha.isPresent()) { accessToken = ficha.consiga().getToken(); si(accessToken != null && !authenticationId.es igual a(este.authenticationKeyGenerator.extractKey(este.readAuthentication(accessToken)))) { este.removeAccessToken(accessToken); este.storeAccessToken(accessToken, autenticación); } } devolver accessToken; } @Override público Colección<OAuth2AccessToken> findTokensByClientIdAndUserName(Cadena clientId, Cadena nombredeusuario) { Colección<OAuth2AccessToken> fichas = nuevo ArrayList<OAuth2AccessToken>(); Lista<CouchbaseAccessToken> resultado = cbAccessTokenRepository.findByClientIdAndUsername(clientId, nombredeusuario); resultado.paraCada(e-> fichas.añada(e.getToken())); devolver fichas; } @Override público Colección<OAuth2AccessToken> findTokensByClientId(Cadena clientId) { Colección<OAuth2AccessToken> fichas = nuevo ArrayList<OAuth2AccessToken>(); Lista<CouchbaseAccessToken> resultado = cbAccessTokenRepository.findByClientId(clientId); resultado.paraCada(e-> fichas.añada(e.getToken())); devolver fichas; } privado Cadena extractTokenKey(Cadena valor) { si(valor == null) { devolver null; } si no { MessageDigest resumen; pruebe { resumen = MessageDigest.getInstance("MD5"); } captura (NoSuchAlgorithmException var5) { tirar nuevo IllegalStateException("MD5 algorithm not available. Fatal (should be in the JDK)."); } pruebe { byte[] e = resumen.resumen(valor.getBytes("UTF-8")); devolver Cadena.formato("%032x", nuevo Objeto[]{nuevo BigInteger(1, e)}); } captura (UnsupportedEncodingException var4) { tirar nuevo IllegalStateException("UTF-8 encoding not available. Fatal (should be in the JDK)."); } } } } |
Finally, we can slightly change our SecurityConfig class, which we have created in the previous article. It will return now an instance of CouchbaseTokenStore en lugar de InMemoryTokenStore:
1 2 3 4 5 6 7 8 9 10 11 |
@Autowired privado CouchbaseAccessTokenRepository couchbaseAccessTokenRepository; @Autowired privado CouchbaseRefreshTokenRepository couchbaseRefreshTokenRepository; @Judía público TokenStore almacén de fichas() { devolver nuevo CouchbaseTokenStore(couchbaseAccessTokenRepository, couchbaseRefreshTokenRepository); } |
Here is the complete version of the SecurityConfig class:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 |
@Configuración @EnableWebMvc público clase SecurityConfig extiende WebSecurityConfigurerAdapter { @Autowired privado CustomUserDetailsService customUserDetailsService; @Autowired privado CouchbaseAccessTokenRepository couchbaseAccessTokenRepository; @Autowired privado CouchbaseRefreshTokenRepository couchbaseRefreshTokenRepository; @Autowired público void globalUserDetails(AuthenticationManagerBuilder auth) lanza Excepción { auth.userDetailsService(customUserDetailsService) .codificador de contraseñas(codificador()); } @Override público void configure( WebSecurity web ) lanza Excepción { web.ignorando().antMatchers( HttpMethod.OPCIONES, "/**" ); } @Override protegido void configure(HttpSecurity http) lanza Excepción { http .csrf().disable() .authorizeRequests() .antMatchers("/oauth/token").permitAll() .antMatchers("/api-docs/**").permitAll() .anyRequest().autentificado() .y().anonymous().disable(); } @Bean público TokenStore almacén de fichas() { devolver nuevo CouchbaseTokenStore(couchbaseAccessTokenRepository, couchbaseRefreshTokenRepository); } @Bean público Codificador de contraseñas codificador(){ devolver NoOpPasswordEncoder.getInstance(); } @Bean público FilterRegistrationBean corsFilter() { UrlBasedCorsConfigurationSource fuente = nuevo UrlBasedCorsConfigurationSource(); CorsConfiguration config = nuevo CorsConfiguration(); config.setAllowCredentials(verdadero); config.addAllowedOrigin("*"); config.addAllowedHeader("*"); config.addAllowedMethod("*"); fuente.registerCorsConfiguration("/**", config); FilterRegistrationBean bean = nuevo FilterRegistrationBean(nuevo CorsFilter(fuente)); bean.setOrder(0); devolver bean; } @Bean @Override público AuthenticationManager authenticationManagerBean() lanza Excepción { devolver super.authenticationManagerBean(); } } |
Well Done! That is all we had to do.
Your access token will look like the following in your database:
1 2 |
SELECCIONE * de prueba donde Clase = 'com.bc.quicktask.standalone.model.CouchbaseAccessToken' |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
[ { "YOUR_BUCKET_NAME": { "_clase": "com.bc.quicktask.standalone.model.CouchbaseAccessToken", "authentication": "rO0ABXNyAEFvcmcuc3ByaW5nZnJhbWV3b3JrLnNlY3VyaXR5Lm9hdXRoMi5wcm92aWRlci5PQXV0aDJBdXRoZW50aWNhdGlvbr1ACwIWYlITAgACTAANc3RvcmVkUmVxdWVzdHQAPExvcmcvc3ByaW5nZnJhbWV3b3JrL3NlY3VyaXR5L29hdXRoMi9wcm92aWRlci9PQXV0aDJSZXF1ZXN0O0wAEnVzZXJBdXRoZW50aWNhdGlvbnQAMkxvcmcvc3ByaW5nZnJhbWV3b3JrL3NlY3VyaXR5L2NvcmUvQXV0aGVudGljYXRpb247eHIAR29yZy5zcHJpbmdmcmFtZXdvcmsuc2VjdXJpdHkuYXV0aGVudGljYXRpb24uQWJzdHJhY3RBdXRoZW50aWNhdGlvblRva2Vu06oofm5HZA4CAANaAA1hdXRoZW50aWNhdGVkTAALYXV0aG9yaXRpZXN0ABZMamF2YS91dGlsL0NvbGxlY3Rpb247TAAHZGV0YWlsc3QAEkxqYXZhL2xhbmcvT2JqZWN0O3hwAHNyACZqYXZhLnV0aWwuQ29sbGVjdGlvbnMkVW5tb2RpZmlhYmxlTGlzdPwPJTG17I4QAgABTAAEbGlzdHQAEExqYXZhL3V0aWwvTGlzdDt4cgAsamF2YS51dGlsLkNvbGxlY3Rpb25zJFVubW9kaWZpYWJsZUNvbGxlY3Rpb24ZQgCAy173HgIAAUwAAWNxAH4ABHhwc3IAE2phdmEudXRpbC5BcnJheUxpc3R4gdIdmcdhnQMAAUkABHNpemV4cAAAAAB3BAAAAAB4cQB+AAxwc3IAOm9yZy5zcHJpbmdmcmFtZXdvcmsuc2VjdXJpdHkub2F1dGgyLnByb3ZpZGVyLk9BdXRoMlJlcXVlc3QAAAAAAAAAAQIAB1oACGFwcHJvdmVkTAALYXV0aG9yaXRpZXNxAH4ABEwACmV4dGVuc2lvbnN0AA9MamF2YS91dGlsL01hcDtMAAtyZWRpcmVjdFVyaXQAEkxqYXZhL2xhbmcvU3RyaW5nO0wAB3JlZnJlc2h0ADtMb3JnL3NwcmluZ2ZyYW1ld29yay9zZWN1cml0eS9vYXV0aDIvcHJvdmlkZXIvVG9rZW5SZXF1ZXN0O0wAC3Jlc291cmNlSWRzdAAPTGphdmEvdXRpbC9TZXQ7TAANcmVzcG9uc2VUeXBlc3EAfgAReHIAOG9yZy5zcHJpbmdmcmFtZXdvcmsuc2VjdXJpdHkub2F1dGgyLnByb3ZpZGVyLkJhc2VSZXF1ZXN0Nih6PqNxab0CAANMAAhjbGllbnRJZHEAfgAPTAARcmVxdWVzdFBhcmFtZXRlcnNxAH4ADkwABXNjb3BlcQB+ABF4cHQACG15Y2xpZW50c3IAJWphdmEudXRpbC5Db2xsZWN0aW9ucyRVbm1vZGlmaWFibGVNYXDxpaj+dPUHQgIAAUwAAW1xAH4ADnhwc3IAEWphdmEudXRpbC5IYXNoTWFwBQfawcMWYNEDAAJGAApsb2FkRmFjdG9ySQAJdGhyZXNob2xkeHA/QAAAAAAABncIAAAACAAAAAN0AApncmFudF90eXBldAAIcGFzc3dvcmR0AAljbGllbnRfaWR0AAhteWNsaWVudHQACHVzZXJuYW1ldAAGbXl1c2VyeHNyACVqYXZhLnV0aWwuQ29sbGVjdGlvbnMkVW5tb2RpZmlhYmxlU2V0gB2S0Y+bgFUCAAB4cQB+AAlzcgAXamF2YS51dGlsLkxpbmtlZEhhc2hTZXTYbNdald0qHgIAAHhyABFqYXZhLnV0aWwuSGFzaFNldLpEhZWWuLc0AwAAeHB3DAAAABA/QAAAAAAAA3QABXRydXN0dAAEcmVhZHQABXdyaXRleAFzcQB+ACJ3DAAAABA/QAAAAAAAAHhzcQB+ABc/QAAAAAAAAHcIAAAAEAAAAAB4cHBzcQB+ACJ3DAAAABA/QAAAAAAAAHhzcQB+ACJ3DAAAABA/QAAAAAAAAHhzcgBPb3JnLnNwcmluZ2ZyYW1ld29yay5zZWN1cml0eS5hdXRoZW50aWNhdGlvbi5Vc2VybmFtZVBhc3N3b3JkQXV0aGVudGljYXRpb25Ub2tlbgAAAAAAAAH0AgACTAALY3JlZGVudGlhbHNxAH4ABUwACXByaW5jaXBhbHEAfgAFeHEAfgADAXNyAB9qYXZhLnV0aWwuQ29sbGVjdGlvbnMkRW1wdHlMaXN0ergXtDynnt4CAAB4cHNyABdqYXZhLnV0aWwuTGlua2VkSGFzaE1hcDTATlwQbMD7AgABWgALYWNjZXNzT3JkZXJ4cQB+ABc/QAAAAAAABncIAAAACAAAAAR0AA1jbGllbnRfc2VjcmV0dAAIbXlzZWNyZXRxAH4AGXEAfgAacQB+ABtxAH4AHHEAfgAdcQB+AB54AHBzcgAyY29tLmJjLnF1aWNrdGFzay5zdGFuZGFsb25lLm1vZGVsLkN1c3RvbVVzZXJEZXRhaWz9dbY7wdosOwIAAkwABmdyb3Vwc3EAfgAITAAEdXNlcnQAKExjb20vYmMvcXVpY2t0YXNrL3N0YW5kYWxvbmUvbW9kZWwvVXNlcjt4cHNxAH4ACwAAAAB3BAAAAAB4c3IAJmNvbS5iYy5xdWlja3Rhc2suc3RhbmRhbG9uZS5tb2RlbC5Vc2VyWvIkR494dqQCAAdMAAljb21wYW55SWRxAH4AD0wADWV4dGVybmFsTG9naW5xAH4AD0wAAmlkcQB+AA9MAAlpc0VuYWJsZWR0ABNMamF2YS9sYW5nL0Jvb2xlYW47TAAJaXNWaXNpYmxlcQB+ADhMAAhwYXNzd29yZHEAfgAPTAAIdXNlcm5hbWVxAH4AD3hwdAAKY29tcGFueS0tMXQABm15dXNlcnQACXVzZXJJZC0tMXNyABFqYXZhLmxhbmcuQm9vbGVhbs0gcoDVnPruAgABWgAFdmFsdWV4cAFxAH4APnQACHBhc3N3b3JkdAAGbXl1c2Vy", "authenticationId": "202d6940ebd428bbe2098530c8de3958", "clientId": "myclient", "refreshToken": "7613ffc6480ae83beb8f0988ef9ecfcf", "token": { "_clase": "org.springframework.security.oauth2.common.DefaultOAuth2AccessToken", "additionalInformation": {}, "caducidad": 1537420023432, "refreshToken": { "_clase": "org.springframework.security.oauth2.common.DefaultExpiringOAuth2RefreshToken", "caducidad": 1537420023421, "valor": "c44db735-403e-49d6-8a22-cfe0e29f21d3" }, "ámbito": [ "confianza", "leer", "escribir" ], "tokenType": "portador", "valor": "7759804c-e1c6-4d63-9520-8737f5b46dbf" }, "tokenId": "12b6bd9de380e1dfab348f4c15abb805", "nombre de usuario": "miusuario" } } ] |
I have used caelwinner’s project as a reference, here is my special thanks to him.
Si tiene alguna pregunta, no dude en tuitearme en @deniswsrosa
Hola Denis,
First of all, thank you for the very brief but concise tutorial. Although it is over a year, it is well explained, and I must acknowledge that you are an excellent teacher.
Please, I have one question and a request to make. Is it possible to add JWT to this implementation OAuth2, and if yes,can you please provide a guide?
Waiting for a reply.
Thank you so much.