🚧 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;
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.auth.utils.JwtUtils;
import de.tavolio.oidc.IssuerService;
@ -70,7 +67,7 @@ public class ClientIdentityProvider implements IdentityProvider<JWTRequest>
KeypairEntity keypairEntity = keypairRepo.findById(kid);
if (keypairEntity != null)
{
PublicKey publicKey = jwksService.findByKid(keypairEntity).toPublicKey();
PublicKey publicKey = jwksService.generate(keypairEntity).toPublicKey();
RealmEntity realm = keypairEntity.getRealm();
try
{

View File

@ -17,6 +17,7 @@ import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.Response;
import org.apache.commons.lang3.NotImplementedException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -83,7 +84,7 @@ public class OidcResource
@POST
@Path("/token")
@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);
@ -103,6 +104,14 @@ public class OidcResource
{
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();
}
@ -116,6 +125,14 @@ public class OidcResource
{
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);
throw new RuntimeException();
}

View File

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

View File

@ -9,10 +9,14 @@ import de.tavolio.realm.code.CodeRepo;
import de.tavolio.realm.RealmEntity;
import de.tavolio.realm.RealmService;
import de.tavolio.realm.key.KeypairEntity;
import de.tavolio.verify.JwtVerificationService;
import io.quarkus.security.identity.SecurityIdentity;
import io.smallrye.jwt.auth.principal.DefaultJWTParser;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import org.apache.commons.lang3.Strings;
import org.eclipse.microprofile.jwt.JsonWebToken;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -41,6 +45,9 @@ public class UserTokenService
@Inject
RealmService realmService;
@Inject
JwtVerificationService jwtVerificationService;
public TokenResponse getToken(String realmKey, String code)
{
return generateUserToken(realmKey, code);
@ -73,4 +80,45 @@ public class UserTokenService
}
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
{
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.KeypairRepo;
import de.tavolio.realm.RealmEntity;
import de.tavolio.verify.jwks.EcPublicKey;
import de.tavolio.verify.jwks.JwksKey;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.apache.commons.lang3.NotImplementedException;
import java.util.LinkedList;
import java.util.List;
import java.util.Optional;
@ApplicationScoped
public class JwksService
{
@Inject
KeypairRepo keypairRepo;
public JwksKey findByKid(KeypairEntity keypair)
public JwksKey generate(KeypairEntity keypair)
{
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.test-port=9089
quarkus.http.host=0.0.0.0
%dev.quarkus.http.port=8089
quarkus.http.access-log.enabled=true
quarkus.http.cors.enabled=true
%dev.quarkus.http.cors.origins=/.*/

View File

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

View File

@ -29,7 +29,7 @@ create table allowed_grants
client_id varchar(255) not null
constraint fkpuqnyqud9ft1tn18qrcy7457c references client,
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[])
)
);