🚧 Add refresh grant

This commit is contained in:
Andreas Dinauer 2026-04-12 13:58:11 +02:00
parent 63d21eaf39
commit 6804ebd46a
10 changed files with 128 additions and 13 deletions

View File

@ -1,8 +1,5 @@
package de.tavolio.auth.identityproviders; package de.tavolio.auth.identityproviders;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.tavolio.Role; import de.tavolio.Role;
import de.tavolio.auth.utils.JwtUtils; import de.tavolio.auth.utils.JwtUtils;
import de.tavolio.oidc.IssuerService; import de.tavolio.oidc.IssuerService;
@ -70,7 +67,7 @@ public class ClientIdentityProvider implements IdentityProvider<JWTRequest>
KeypairEntity keypairEntity = keypairRepo.findById(kid); KeypairEntity keypairEntity = keypairRepo.findById(kid);
if (keypairEntity != null) if (keypairEntity != null)
{ {
PublicKey publicKey = jwksService.findByKid(keypairEntity).toPublicKey(); PublicKey publicKey = jwksService.generate(keypairEntity).toPublicKey();
RealmEntity realm = keypairEntity.getRealm(); RealmEntity realm = keypairEntity.getRealm();
try try
{ {

View File

@ -17,6 +17,7 @@ import jakarta.inject.Inject;
import jakarta.transaction.Transactional; import jakarta.transaction.Transactional;
import jakarta.ws.rs.*; import jakarta.ws.rs.*;
import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response;
import org.apache.commons.lang3.NotImplementedException;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -83,7 +84,7 @@ public class OidcResource
@POST @POST
@Path("/token") @Path("/token")
@Transactional @Transactional
public TokenResponse token(@FormParam("grant_type") String grantType, @FormParam("code") String code) throws NoSuchAlgorithmException, InvalidKeySpecException public TokenResponse token(@FormParam("grant_type") String grantType, @FormParam("code") String code, @FormParam("refresh_token") String refreshToken) throws NoSuchAlgorithmException, InvalidKeySpecException
{ {
Grant grant = getGrant(grantType); Grant grant = getGrant(grantType);
@ -103,6 +104,14 @@ public class OidcResource
{ {
return clientTokenService.getToken(realmKey); return clientTokenService.getToken(realmKey);
} }
if (Grant.PASSWORD.equals(grant))
{
throw new NotImplementedException();
}
if (Grant.REFRESH_TOKEN.equals(grant))
{
return userTokenService.refreshAccessToken(realmKey, refreshToken);
}
throw new RuntimeException(); throw new RuntimeException();
} }
@ -116,6 +125,14 @@ public class OidcResource
{ {
return Grant.CLIENT_CREDENTIALS; return Grant.CLIENT_CREDENTIALS;
} }
if ("refresh_token".equals(grantType))
{
return Grant.REFRESH_TOKEN;
}
if ("password".equals(grantType))
{
return Grant.PASSWORD;
}
LOG.error("Invalid grant {} provided", grantType); LOG.error("Invalid grant {} provided", grantType);
throw new RuntimeException(); throw new RuntimeException();
} }

View File

@ -19,6 +19,7 @@ public class UserTokenGenerator
return Jwt.claims() return Jwt.claims()
.upn(upn) .upn(upn)
.audience(clientId) .audience(clientId)
.claim("token_purpose", "access_token")
.claim("realm_key", realmKey) .claim("realm_key", realmKey)
.claim("client_id", clientId) .claim("client_id", clientId)
.expiresAt(expiresAt.toInstant()) .expiresAt(expiresAt.toInstant())
@ -30,6 +31,7 @@ public class UserTokenGenerator
{ {
return Jwt.claims() return Jwt.claims()
.upn(upn) .upn(upn)
.claim("token_purpose", "refresh_token")
.claim("realm_key", realmKey) .claim("realm_key", realmKey)
.claim("client_id", clientId) .claim("client_id", clientId)
.expiresAt(expiresAt.toInstant()) .expiresAt(expiresAt.toInstant())

View File

@ -9,10 +9,14 @@ import de.tavolio.realm.code.CodeRepo;
import de.tavolio.realm.RealmEntity; import de.tavolio.realm.RealmEntity;
import de.tavolio.realm.RealmService; import de.tavolio.realm.RealmService;
import de.tavolio.realm.key.KeypairEntity; import de.tavolio.realm.key.KeypairEntity;
import de.tavolio.verify.JwtVerificationService;
import io.quarkus.security.identity.SecurityIdentity; import io.quarkus.security.identity.SecurityIdentity;
import io.smallrye.jwt.auth.principal.DefaultJWTParser;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import jakarta.ws.rs.*; import jakarta.ws.rs.*;
import org.apache.commons.lang3.Strings;
import org.eclipse.microprofile.jwt.JsonWebToken;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -41,6 +45,9 @@ public class UserTokenService
@Inject @Inject
RealmService realmService; RealmService realmService;
@Inject
JwtVerificationService jwtVerificationService;
public TokenResponse getToken(String realmKey, String code) public TokenResponse getToken(String realmKey, String code)
{ {
return generateUserToken(realmKey, code); return generateUserToken(realmKey, code);
@ -73,4 +80,45 @@ public class UserTokenService
} }
throw new BadRequestException(); throw new BadRequestException();
} }
public TokenResponse refreshAccessToken(String realmKey, String refreshToken)
{
String principal = identity.getPrincipal().getName();
JsonWebToken token = jwtVerificationService.validate(realmKey, refreshToken);
String clientId = token.getClaim("client_id");
assertClientId(clientId, principal);
String purpose = token.getClaim("token_purpose");
assertTokenPurpose(purpose);
RealmEntity realm = realmService.requireByKey(realmKey);
KeypairEntity keypair = realm.getKeys().getFirst();
String kid = keypair.getId();
PrivateKey signingKey = KeypairEntity.toPrivateKey(keypair);
ZonedDateTime expiresAt = ZonedDateTime.now().plusSeconds(realm.getLifetime());
return new TokenResponse()
.setTokenType("Bearer")
.setExpiresAt(expiresAt.toEpochSecond())
.setAccessToken(userTokenGenerator.generateAccessToken(realm.getKey(), principal, token.getName(), expiresAt, signingKey, kid))
.setRefreshToken(refreshToken);
}
private void assertClientId(String tokenClientId, String requestingClientId)
{
if (!Strings.CI.equals(tokenClientId, requestingClientId))
{
throw new RuntimeException("JWT client id and requesting client id do not match. Deny refresh.");
}
}
private void assertTokenPurpose(String purpose)
{
if (!Strings.CI.equals("refresh_token", purpose))
{
throw new RuntimeException("Provided token is not a refresh token. Please provide a refresh token.");
}
}
} }

View File

@ -2,5 +2,5 @@ package de.tavolio.realm.client;
public enum Grant public enum Grant
{ {
CLIENT_CREDENTIALS, AUTHORIZATION_CODE, PASSWORD CLIENT_CREDENTIALS, AUTHORIZATION_CODE, PASSWORD, REFRESH_TOKEN
} }

View File

@ -2,24 +2,19 @@ package de.tavolio.verify;
import de.tavolio.realm.key.KeypairEntity; import de.tavolio.realm.key.KeypairEntity;
import de.tavolio.realm.key.KeypairRepo; import de.tavolio.realm.key.KeypairRepo;
import de.tavolio.realm.RealmEntity;
import de.tavolio.verify.jwks.EcPublicKey; import de.tavolio.verify.jwks.EcPublicKey;
import de.tavolio.verify.jwks.JwksKey; import de.tavolio.verify.jwks.JwksKey;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import org.apache.commons.lang3.NotImplementedException; import org.apache.commons.lang3.NotImplementedException;
import java.util.LinkedList;
import java.util.List;
import java.util.Optional;
@ApplicationScoped @ApplicationScoped
public class JwksService public class JwksService
{ {
@Inject @Inject
KeypairRepo keypairRepo; KeypairRepo keypairRepo;
public JwksKey findByKid(KeypairEntity keypair) public JwksKey generate(KeypairEntity keypair)
{ {
switch (keypair.getType()) switch (keypair.getType())
{ {

View File

@ -0,0 +1,51 @@
package de.tavolio.verify;
import de.tavolio.auth.utils.JwtUtils;
import de.tavolio.oidc.IssuerService;
import de.tavolio.realm.key.KeypairEntity;
import de.tavolio.realm.key.KeypairRepo;
import io.quarkus.security.AuthenticationFailedException;
import io.quarkus.security.UnauthorizedException;
import io.smallrye.jwt.auth.principal.DefaultJWTParser;
import io.smallrye.jwt.auth.principal.JWTAuthContextInfo;
import io.smallrye.jwt.auth.principal.ParseException;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.eclipse.microprofile.jwt.JsonWebToken;
@ApplicationScoped
public class JwtVerificationService
{
@Inject
IssuerService issuerService;
@Inject
JwksService jwksService;
@Inject
KeypairRepo keypairRepo;
public JsonWebToken validate(String realmKey, String jwt)
{
KeypairEntity keypair = keypairRepo.findById(JwtUtils.parseHeader(jwt).getKid());
if (keypair != null)
{
try
{
return new DefaultJWTParser(getContextForRealm(realmKey)).verify(jwt, jwksService.generate(keypair).toPublicKey());
}
catch (ParseException e)
{
throw new UnauthorizedException();
}
}
throw new UnauthorizedException();
}
private JWTAuthContextInfo getContextForRealm(String realmKey)
{
JWTAuthContextInfo info = new JWTAuthContextInfo();
info.setIssuedBy(issuerService.getIssuer(realmKey));
return info;
}
}

View File

@ -1,8 +1,12 @@
quarkus.jvm-args=--add-opens java.base/java.lang=ALL-UNNAMED
quarkus.http.root-path=/api quarkus.http.root-path=/api
quarkus.http.test-port=9089 quarkus.http.test-port=9089
quarkus.http.host=0.0.0.0 quarkus.http.host=0.0.0.0
%dev.quarkus.http.port=8089 %dev.quarkus.http.port=8089
quarkus.http.access-log.enabled=true
quarkus.http.cors.enabled=true quarkus.http.cors.enabled=true
%dev.quarkus.http.cors.origins=/.*/ %dev.quarkus.http.cors.origins=/.*/

View File

@ -22,6 +22,7 @@ realms:
allowed-grants: allowed-grants:
- AUTHORIZATION_CODE - AUTHORIZATION_CODE
- CLIENT_CREDENTIALS - CLIENT_CREDENTIALS
- REFRESH_TOKEN
analytics-backend: analytics-backend:
secret: secret:
bcrypt: $2a$12$1oYS45e/nXP1OeMgdZZAKeEixarRDzbBGZd0xOnEQQMKlOKwVMrX. bcrypt: $2a$12$1oYS45e/nXP1OeMgdZZAKeEixarRDzbBGZd0xOnEQQMKlOKwVMrX.

View File

@ -29,7 +29,7 @@ create table allowed_grants
client_id varchar(255) not null client_id varchar(255) not null
constraint fkpuqnyqud9ft1tn18qrcy7457c references client, constraint fkpuqnyqud9ft1tn18qrcy7457c references client,
grant_name varchar(255) grant_name varchar(255)
constraint allowed_grants_grant_name_check check ((grant_name)::text = ANY ((ARRAY ['CLIENT_CREDENTIALS':: character varying, 'AUTHORIZATION_CODE':: character varying, 'PASSWORD':: character varying])::text[]) constraint allowed_grants_grant_name_check check ((grant_name)::text = ANY ((ARRAY ['CLIENT_CREDENTIALS':: character varying, 'AUTHORIZATION_CODE':: character varying, 'PASSWORD':: character varying, 'REFRESH_TOKEN':: character varying])::text[])
) )
); );