♻️ Transform to IDP provider

This commit is contained in:
Andreas Dinauer 2026-03-14 18:54:50 +01:00
parent 79104dd02f
commit 7d638e6530
37 changed files with 531 additions and 326 deletions

View File

@ -1,59 +0,0 @@
package de.tavolio.bootstrap;
import de.tavolio.realm.user.UserEntity;
import de.tavolio.realm.user.UserRepo;
import de.tavolio.realm.user.UserStatus;
import de.tavolio.bootstrap.model.Account;
import de.tavolio.realm.RealmEntity;
import io.quarkus.elytron.security.common.BcryptUtil;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@ApplicationScoped
public class AccountBootstrapper
{
@Inject
UserRepo userRepo;
public void bootstrap(RealmEntity realm, List<Account> accounts)
{
for (Account account : accounts)
{
run(realm, account);
}
}
public void run(RealmEntity realm, Account account)
{
Optional<UserEntity> existingAccount = userRepo.findOptionalByRealmAndEmail(realm, account.email());
if (existingAccount.isEmpty())
{
UserEntity newAccount = new UserEntity();
newAccount.setId(UUID.randomUUID().toString());
newAccount.setEmail(account.email());
newAccount.setFirstname(account.firstname());
newAccount.setLastname(account.lastname());
newAccount.setRealm(realm);
newAccount.setStatus(UserStatus.INIT);
newAccount.setPassword(resolvePassword(account));
userRepo.persist(newAccount);
}
}
private String resolvePassword(Account account)
{
if (account.passwordFromEnv() != null)
{
return BcryptUtil.bcryptHash(System.getenv(account.passwordFromEnv()));
}
if (account.passwordPlain() != null)
{
return BcryptUtil.bcryptHash(account.passwordPlain());
}
return null;
}
}

View File

@ -9,6 +9,7 @@ import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import java.util.Map; import java.util.Map;
import java.util.Optional;
@ApplicationScoped @ApplicationScoped
public class ClientBootstrapper public class ClientBootstrapper
@ -23,8 +24,23 @@ public class ClientBootstrapper
{ {
for (Map.Entry<String, Client> clientEntry : clients.entrySet()) for (Map.Entry<String, Client> clientEntry : clients.entrySet())
{ {
ClientEntity client = clientService.findOrCreate(realm, clientEntry); run(realm, clientEntry);
clientRepo.persist(client); }
}
private void run(RealmEntity realm, Map.Entry<String, Client> bootstrap)
{
Optional<ClientEntity> existingClient = clientRepo.findByRealmAndIdOptional(realm, bootstrap.getKey());
if (existingClient.isEmpty())
{
String id = bootstrap.getKey();
Client client = bootstrap.getValue();
ClientEntity entity = new ClientEntity().setId(id)
.setSecret(Credentials.resolve(client.secret()))
.setRedirectURI(client.redirectURI())
.setRealm(realm)
.setPermissions(client.permissions());
clientRepo.persist(entity);
} }
} }
} }

View File

@ -0,0 +1,36 @@
package de.tavolio.bootstrap;
import de.tavolio.bootstrap.model.Credential;
import de.tavolio.bootstrap.model.User;
import io.quarkus.elytron.security.common.BcryptUtil;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Strings;
public class Credentials
{
public static String resolve(Credential credential)
{
if (!StringUtils.isBlank(credential.bcrypt()))
{
if (Strings.CI.startsWithAny(credential.bcrypt(), "$2a$", "$2b$", "$2y$"))
{
return credential.bcrypt();
}
throw new IllegalStateException();
}
if (!StringUtils.isBlank(credential.env()))
{
String value = System.getenv(credential.env());
if (!StringUtils.isBlank(value))
{
return BcryptUtil.bcryptHash(value);
}
throw new IllegalStateException();
}
if (!StringUtils.isBlank(credential.plain()))
{
return BcryptUtil.bcryptHash(credential.plain());
}
throw new IllegalArgumentException("Cannot find password source.");
}
}

View File

@ -26,14 +26,11 @@ public class RealmBootstrapService
@Inject @Inject
ClientBootstrapper clientBootstrapper; ClientBootstrapper clientBootstrapper;
@Inject
RoleBootstrapper roleBootstrapper;
@Inject @Inject
RealmRepo realmRepo; RealmRepo realmRepo;
@Inject @Inject
AccountBootstrapper accountBootstrapper; UserBootstrapper userBootstrapper;
@Inject @Inject
AudienceStrategyRepo audienceStrategyRepo; AudienceStrategyRepo audienceStrategyRepo;
@ -42,10 +39,9 @@ public class RealmBootstrapService
{ {
Realm realmValue = realmEntry.getValue(); Realm realmValue = realmEntry.getValue();
RealmEntity realm = run(realmEntry.getKey(), realmEntry.getValue()); RealmEntity realm = run(realmEntry.getKey(), realmEntry.getValue());
roleBootstrapper.bootstrap(realm, realmValue.roles());
keyBootstrapper.bootstrap(realm, realmValue.key()); keyBootstrapper.bootstrap(realm, realmValue.key());
clientBootstrapper.bootstrap(realm, realmValue.clients()); clientBootstrapper.bootstrap(realm, realmValue.clients());
accountBootstrapper.bootstrap(realm, realmValue.accounts()); userBootstrapper.bootstrap(realm, realmValue.users());
} }
public RealmEntity run(String key, Realm realm) public RealmEntity run(String key, Realm realm)

View File

@ -1,43 +0,0 @@
package de.tavolio.bootstrap;
import de.tavolio.bootstrap.model.Role;
import de.tavolio.realm.RealmEntity;
import de.tavolio.realm.role.RoleEntity;
import de.tavolio.realm.role.RoleRepo;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import java.util.Map;
import java.util.UUID;
@ApplicationScoped
public class RoleBootstrapper
{
@Inject
RoleRepo roleRepo;
public void bootstrap(RealmEntity realm, Map<String, Role> roles)
{
for (Map.Entry<String, Role> roleEntry : roles.entrySet())
{
run(realm, roleEntry);
}
}
public void run(RealmEntity realm, Map.Entry<String, Role> roleEntry)
{
RoleEntity role = getOrCreateRole(realm, roleEntry.getKey());
for (String permission : roleEntry.getValue().permissions())
{
if (!role.hasPermission(permission))
{
role.getPermissions().add(permission);
}
}
roleRepo.persist(role);
}
public RoleEntity getOrCreateRole(RealmEntity realm, String role)
{
return roleRepo.findByNameAndRealmOptional(role, realm).orElse(new RoleEntity().setId(UUID.randomUUID().toString()).setName(role).setRealm(realm));
}
}

View File

@ -20,8 +20,8 @@ public class SuperuserBootstrapper
public void bootstrap() public void bootstrap()
{ {
Config config = ConfigProvider.getConfig(); Config config = ConfigProvider.getConfig();
String username = config.getValue("dev.dinauer.idp.superuser.username", String.class); String username = config.getValue("io.verifoo.superuser.username", String.class);
String password = config.getValue("dev.dinauer.idp.superuser.password", String.class); String password = config.getValue("io.verifoo.superuser.password", String.class);
if (!StringUtils.isBlank(username) && !StringUtils.isBlank(password) && superuserRepo.count() == 0) if (!StringUtils.isBlank(username) && !StringUtils.isBlank(password) && superuserRepo.count() == 0)
{ {
SuperuserEntity superuser = new SuperuserEntity(); SuperuserEntity superuser = new SuperuserEntity();

View File

@ -0,0 +1,48 @@
package de.tavolio.bootstrap;
import de.tavolio.realm.user.UserEntity;
import de.tavolio.realm.user.UserRepo;
import de.tavolio.realm.user.UserStatus;
import de.tavolio.bootstrap.model.User;
import de.tavolio.realm.RealmEntity;
import io.quarkus.elytron.security.common.BcryptUtil;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Strings;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@ApplicationScoped
public class UserBootstrapper
{
@Inject
UserRepo userRepo;
public void bootstrap(RealmEntity realm, List<User> users)
{
for (User user : users)
{
run(realm, user);
}
}
public void run(RealmEntity realm, User user)
{
Optional<UserEntity> existingAccount = userRepo.findOptionalByRealmAndEmail(realm, user.email());
if (existingAccount.isEmpty())
{
UserEntity newAccount = new UserEntity();
newAccount.setId(UUID.randomUUID().toString());
newAccount.setEmail(user.email());
newAccount.setFirstname(user.firstname());
newAccount.setLastname(user.lastname());
newAccount.setRealm(realm);
newAccount.setStatus(UserStatus.INIT);
newAccount.setPassword(Credentials.resolve(user.password()));
userRepo.persist(newAccount);
}
}
}

View File

@ -1,7 +0,0 @@
package de.tavolio.bootstrap.model;
import com.fasterxml.jackson.annotation.JsonProperty;
public record Account(String email, @JsonProperty("first-name") String firstname, @JsonProperty("last-name") String lastname, @JsonProperty("password-env") String passwordFromEnv, @JsonProperty("password-plain") String passwordPlain)
{
}

View File

@ -1,9 +1,15 @@
package de.tavolio.bootstrap.model; package de.tavolio.bootstrap.model;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import de.tavolio.realm.user.Permission;
import java.util.List; import java.util.Set;
import java.util.stream.Collectors;
public record Client(@JsonProperty("client-secret") String clientSecret, @JsonProperty("redirect-uri") String redirectURI, List<String> roles) public record Client(@JsonProperty("secret") Credential secret, @JsonProperty("redirect-uri") String redirectURI, @JsonProperty("permissions") Set<String> permissionList)
{ {
public Set<Permission> permissions()
{
return permissionList.stream().map(item -> Permission.valueOf(item.toUpperCase())).collect(Collectors.toSet());
}
} }

View File

@ -0,0 +1,5 @@
package de.tavolio.bootstrap.model;
public record Credential(String plain, String bcrypt, String env)
{
}

View File

@ -3,6 +3,6 @@ package de.tavolio.bootstrap.model;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
public record Realm(String name, Key key, Audience audience, Map<String, Client> clients, Map<String, Role> roles, List<String> permissions, List<Account> accounts) public record Realm(String name, Key key, Audience audience, Map<String, Client> clients, Map<String, Role> roles, List<String> permissions, List<User> users)
{ {
} }

View File

@ -0,0 +1,7 @@
package de.tavolio.bootstrap.model;
import com.fasterxml.jackson.annotation.JsonProperty;
public record User(String email, @JsonProperty("first-name") String firstname, @JsonProperty("last-name") String lastname, Credential password)
{
}

View File

@ -6,7 +6,7 @@ import org.eclipse.microprofile.config.inject.ConfigProperty;
@ApplicationScoped @ApplicationScoped
public class IssuerService public class IssuerService
{ {
@ConfigProperty(name = "dev.dinauer.idp.origin") @ConfigProperty(name = "io.verifoo.http.origin")
String origin; String origin;
public String getIssuer(String realmKey) public String getIssuer(String realmKey)

View File

@ -16,7 +16,7 @@ public class OidcConfigurationResource
@Inject @Inject
IssuerService issuerService; IssuerService issuerService;
@ConfigProperty(name = "dev.dinauer.idp.origin") @ConfigProperty(name = "io.verifoo.http.origin")
String origin; String origin;
@GET @GET

View File

@ -1,9 +1,10 @@
package de.tavolio.oidc; package de.tavolio.oidc;
import de.tavolio.oidc.session.AuthorizationService; import de.tavolio.oidc.auth.AuthorizationService;
import de.tavolio.oidc.session.dto.SessionCreation; import de.tavolio.oidc.auth.model.AuthorizationCreation;
import de.tavolio.oidc.token.ClientTokenService;
import de.tavolio.oidc.token.model.TokenResponse; import de.tavolio.oidc.token.model.TokenResponse;
import de.tavolio.oidc.token.TokenService; import de.tavolio.oidc.token.UserTokenService;
import jakarta.annotation.security.RolesAllowed; import jakarta.annotation.security.RolesAllowed;
import jakarta.enterprise.context.RequestScoped; import jakarta.enterprise.context.RequestScoped;
import jakarta.inject.Inject; import jakarta.inject.Inject;
@ -26,11 +27,14 @@ public class OidcResource
JwksService jwksService; JwksService jwksService;
@Inject @Inject
TokenService tokenService; UserTokenService userTokenService;
@Inject @Inject
AuthorizationService authorizationService; AuthorizationService authorizationService;
@Inject
ClientTokenService clientTokenService;
@GET @GET
@Path("/certs") @Path("/certs")
public Map<String, Object> certs() public Map<String, Object> certs()
@ -42,7 +46,7 @@ public class OidcResource
@Path("/auth") @Path("/auth")
public Response auth(@QueryParam("client_id") String clientId, @FormParam("email") String email, @FormParam("password") String password) public Response auth(@QueryParam("client_id") String clientId, @FormParam("email") String email, @FormParam("password") String password)
{ {
String code = authorizationService.generateBySessionCreation(realmKey, clientId, new SessionCreation(email, password)); String code = authorizationService.generateBySessionCreation(realmKey, clientId, new AuthorizationCreation(email, password));
return Response.status(302).location(URI.create("http://localhost:8080/callback?code=" + code + "&state=d")).build(); return Response.status(302).location(URI.create("http://localhost:8080/callback?code=" + code + "&state=d")).build();
} }
@ -50,15 +54,15 @@ public class OidcResource
@Path("/token") @Path("/token")
@RolesAllowed("CLIENT") @RolesAllowed("CLIENT")
@Transactional @Transactional
public TokenResponse token(@FormParam("grant_type") String grantType, @FormParam("client_id") String clientId, @FormParam("code") String code) throws NoSuchAlgorithmException, InvalidKeySpecException public TokenResponse token(@FormParam("grant_type") String grantType, @FormParam("code") String code) throws NoSuchAlgorithmException, InvalidKeySpecException
{ {
if (GrantType.AUTH_CODE.equals(GrantType.fromValue(grantType))) if (GrantType.AUTH_CODE.equals(GrantType.fromValue(grantType)))
{ {
return tokenService.getUserToken(realmKey, code); return userTokenService.getToken(realmKey, code);
} }
if (GrantType.CLIENT_CREDENTIALS.equals(GrantType.fromValue(grantType))) if (GrantType.CLIENT_CREDENTIALS.equals(GrantType.fromValue(grantType)))
{ {
return tokenService.getClientToken(realmKey); return clientTokenService.getToken(realmKey);
} }
throw new RuntimeException(); throw new RuntimeException();
} }

View File

@ -1,4 +1,4 @@
package de.tavolio.oidc.session; package de.tavolio.oidc.auth;
import de.tavolio.realm.RealmService; import de.tavolio.realm.RealmService;
import de.tavolio.realm.user.UserEntity; import de.tavolio.realm.user.UserEntity;
@ -8,7 +8,7 @@ import de.tavolio.realm.client.ClientService;
import de.tavolio.realm.code.CodeEntity; import de.tavolio.realm.code.CodeEntity;
import de.tavolio.realm.code.CodeRepo; import de.tavolio.realm.code.CodeRepo;
import de.tavolio.realm.RealmEntity; import de.tavolio.realm.RealmEntity;
import de.tavolio.oidc.session.dto.SessionCreation; import de.tavolio.oidc.auth.model.AuthorizationCreation;
import io.quarkus.elytron.security.common.BcryptUtil; import io.quarkus.elytron.security.common.BcryptUtil;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject; import jakarta.inject.Inject;
@ -35,16 +35,16 @@ public class AuthorizationService
ClientService clientService; ClientService clientService;
@Transactional @Transactional
public String generateBySessionCreation(String realmKey, String clientId, SessionCreation sessionCreation) public String generateBySessionCreation(String realmKey, String clientId, AuthorizationCreation authorizationCreation)
{ {
RealmEntity realm = realmService.requireByKey(realmKey); RealmEntity realm = realmService.requireByKey(realmKey);
ClientEntity client = clientService.findByIdAndRealm(clientId, realm); ClientEntity client = clientService.findByIdAndRealm(clientId, realm);
Optional<UserEntity> accountEntityOptional = userRepo.findOptionalByRealmAndEmail(realm, sessionCreation.email()); Optional<UserEntity> accountEntityOptional = userRepo.findOptionalByRealmAndEmail(realm, authorizationCreation.email());
if (accountEntityOptional.isPresent()) if (accountEntityOptional.isPresent())
{ {
UserEntity userEntity = accountEntityOptional.get(); UserEntity userEntity = accountEntityOptional.get();
if (BcryptUtil.matches(sessionCreation.password(), userEntity.getPassword())) if (BcryptUtil.matches(authorizationCreation.password(), userEntity.getPassword()))
{ {
CodeEntity code = new CodeEntity().setId(UUID.randomUUID().toString()); CodeEntity code = new CodeEntity().setId(UUID.randomUUID().toString());
code.setExpiresAt(ZonedDateTime.now().plusMinutes(1)); code.setExpiresAt(ZonedDateTime.now().plusMinutes(1));

View File

@ -1,9 +1,9 @@
package de.tavolio.oidc.session.dto; package de.tavolio.oidc.auth.model;
import jakarta.validation.constraints.Email; import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
public record SessionCreation( public record AuthorizationCreation(
@Email String email, @Email String email,
@NotBlank String password) @NotBlank String password)
{ {

View File

@ -1,4 +1,4 @@
package de.tavolio.oidc.auth; package de.tavolio.oidc.identityproviders;
import de.tavolio.Role; import de.tavolio.Role;
import de.tavolio.realm.user.UserRepo; import de.tavolio.realm.user.UserRepo;
@ -16,20 +16,20 @@ import io.quarkus.security.runtime.QuarkusPrincipal;
import io.quarkus.security.runtime.QuarkusSecurityIdentity; import io.quarkus.security.runtime.QuarkusSecurityIdentity;
import io.smallrye.mutiny.Uni; import io.smallrye.mutiny.Uni;
import io.smallrye.mutiny.infrastructure.Infrastructure; import io.smallrye.mutiny.infrastructure.Infrastructure;
import jakarta.annotation.Priority;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.context.control.ActivateRequestContext; import jakarta.enterprise.context.control.ActivateRequestContext;
import jakarta.inject.Inject; import jakarta.inject.Inject;
@ApplicationScoped @ApplicationScoped
public class OidcClientIdentityProvider implements IdentityProvider<UsernamePasswordAuthenticationRequest> @Priority(1)
public class BasicAuthIdentityProvider implements IdentityProvider<UsernamePasswordAuthenticationRequest>
{ {
@Inject @Inject
ClientRepo clientRepo; ClientRepo clientRepo;
@Inject @Inject
SuperuserRepo superuserRepo; SuperuserRepo superuserRepo;
@Inject
UserRepo userRepo;
@Override @Override
public Class<UsernamePasswordAuthenticationRequest> getRequestType() public Class<UsernamePasswordAuthenticationRequest> getRequestType()

View File

@ -0,0 +1,129 @@
package de.tavolio.oidc.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.oidc.IssuerService;
import de.tavolio.realm.RealmEntity;
import de.tavolio.realm.client.ClientEntity;
import de.tavolio.realm.client.ClientService;
import de.tavolio.realm.key.KeypairEntity;
import de.tavolio.realm.key.KeypairRepo;
import de.tavolio.verify.JwksService;
import de.tavolio.verify.jwks.JwksKey;
import io.quarkus.security.AuthenticationFailedException;
import io.quarkus.security.StringPermission;
import io.quarkus.security.identity.AuthenticationRequestContext;
import io.quarkus.security.identity.IdentityProvider;
import io.quarkus.security.identity.SecurityIdentity;
import io.quarkus.security.identity.request.TokenAuthenticationRequest;
import io.quarkus.security.runtime.QuarkusPrincipal;
import io.quarkus.security.runtime.QuarkusSecurityIdentity;
import io.smallrye.jwt.auth.principal.DefaultJWTParser;
import io.smallrye.jwt.auth.principal.JWTAuthContextInfo;
import io.smallrye.jwt.auth.principal.ParseException;
import io.smallrye.mutiny.Uni;
import io.smallrye.mutiny.infrastructure.Infrastructure;
import jakarta.annotation.Priority;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.context.control.ActivateRequestContext;
import jakarta.inject.Inject;
import org.eclipse.microprofile.jwt.JsonWebToken;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.security.Permission;
import java.security.PublicKey;
import java.util.*;
import java.util.stream.Collectors;
@ApplicationScoped
@Priority(1)
public class ClientIdentityProvider implements IdentityProvider<TokenAuthenticationRequest>
{
private static final Logger LOG = LoggerFactory.getLogger(ClientIdentityProvider.class);
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
@Inject
JwksService jwksService;
@Inject
KeypairRepo keypairRepo;
@Inject
IssuerService issuerService;
@Inject
ClientService clientService;
@Override
public Class<TokenAuthenticationRequest> getRequestType()
{
return TokenAuthenticationRequest.class;
}
@Override
@ActivateRequestContext
public Uni<SecurityIdentity> authenticate(TokenAuthenticationRequest tokenAuthenticationRequest, AuthenticationRequestContext authenticationRequestContext)
{
String raw = tokenAuthenticationRequest.getToken().getToken();
try
{
Object kid = getHeader(raw).get("kid");
if (kid instanceof String keyId)
{
return Uni.createFrom().item(() -> {
KeypairEntity keypairEntity = keypairRepo.findById(keyId);
if (keypairEntity != null)
{
PublicKey publicKey = jwksService.findByKid(keypairEntity).toPublicKey();
RealmEntity realm = keypairEntity.getRealm();
try
{
JsonWebToken token = new DefaultJWTParser(getContextForRealm(realm.getKey())).verify(raw, publicKey);
ClientEntity client = clientService.findByIdAndRealm(token.getName(), realm);
if (client != null)
{
return (SecurityIdentity) QuarkusSecurityIdentity.builder().setPrincipal(new QuarkusPrincipal(client.getId())).addRole(Role.CLIENT.toString()).addAttribute("permissions", new HashSet<>(client.getPermissions())).build();
}
}
catch (ParseException e)
{
throw new RuntimeException(e);
}
}
LOG.error("Cannot find key with id {}", kid);
throw new AuthenticationFailedException();
}).runSubscriptionOn(Infrastructure.getDefaultWorkerPool());
}
return Uni.createFrom().nullItem();
}
catch (JsonProcessingException e)
{
return Uni.createFrom().failure(new AuthenticationFailedException());
}
}
private Map<String, Object> getHeader(String raw) throws JsonProcessingException
{
return OBJECT_MAPPER.readValue(new String(Base64.getUrlDecoder().decode(section(raw))), new TypeReference<Map<String, Object>>(){});
}
private String section(String raw)
{
String[] sections = raw.split("\\.");
if (sections.length == 3)
{
return sections[0];
}
throw new RuntimeException();
}
private JWTAuthContextInfo getContextForRealm(String realmKey)
{
JWTAuthContextInfo info = new JWTAuthContextInfo();
info.setIssuedBy(issuerService.getIssuer(realmKey));
return info;
}
}

View File

@ -0,0 +1,29 @@
package de.tavolio.oidc.token;
import de.tavolio.oidc.IssuerService;
import io.smallrye.jwt.build.Jwt;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import java.security.PrivateKey;
import java.time.ZonedDateTime;
@ApplicationScoped
public class ClientTokenGenerator
{
@Inject
IssuerService issuerService;
public String generateAccessToken(String realmKey, String clientId, ZonedDateTime expiresAt, PrivateKey key, String keyId)
{
return Jwt.claims()
.upn(clientId)
.audience(clientId)
.subject(clientId)
.claim("realm_key", realmKey)
.claim("client_id", clientId)
.expiresAt(expiresAt.toInstant())
.issuer(issuerService.getIssuer(realmKey)).jws().keyId(keyId)
.sign(key);
}
}

View File

@ -0,0 +1,48 @@
package de.tavolio.oidc.token;
import de.tavolio.oidc.token.model.TokenResponse;
import de.tavolio.realm.RealmEntity;
import de.tavolio.realm.RealmService;
import de.tavolio.realm.client.ClientEntity;
import de.tavolio.realm.client.ClientService;
import de.tavolio.realm.key.KeypairEntity;
import io.quarkus.security.identity.SecurityIdentity;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.BadRequestException;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.spec.InvalidKeySpecException;
import java.time.ZonedDateTime;
@ApplicationScoped
public class ClientTokenService
{
@Inject
RealmService realmService;
@Inject
ClientService clientService;
@Inject
SecurityIdentity identity;
@Inject
ClientTokenGenerator clientTokenGenerator;
public TokenResponse getToken(String realmKey)
{
RealmEntity realm = realmService.requireByKey(realmKey);
ClientEntity client = clientService.findByIdAndRealm(identity.getPrincipal().getName(), realm);
if (client != null)
{
KeypairEntity keypair = realm.getKeys().getFirst();
PrivateKey signingKey = KeypairEntity.toPrivateKey(keypair);
ZonedDateTime expiresAt = ZonedDateTime.now().plusYears(1);
String token = clientTokenGenerator.generateAccessToken(realmKey, client.getId(), expiresAt, signingKey, keypair.getId());
return new TokenResponse().setAccessToken(token).setTokenType("Bearer").setExpiresAt(expiresAt.toInstant().getEpochSecond());
}
throw new BadRequestException();
}
}

View File

@ -9,7 +9,7 @@ import java.security.PrivateKey;
import java.time.ZonedDateTime; import java.time.ZonedDateTime;
@ApplicationScoped @ApplicationScoped
public class TokenGenerator public class UserTokenGenerator
{ {
@Inject @Inject
IssuerService issuerService; IssuerService issuerService;

View File

@ -2,6 +2,8 @@ package de.tavolio.oidc.token;
import de.tavolio.oidc.token.model.TokenResponse; import de.tavolio.oidc.token.model.TokenResponse;
import de.tavolio.realm.RealmRepo; import de.tavolio.realm.RealmRepo;
import de.tavolio.realm.client.ClientEntity;
import de.tavolio.realm.client.ClientService;
import de.tavolio.realm.code.CodeEntity; import de.tavolio.realm.code.CodeEntity;
import de.tavolio.realm.code.CodeRepo; import de.tavolio.realm.code.CodeRepo;
import de.tavolio.realm.RealmEntity; import de.tavolio.realm.RealmEntity;
@ -11,41 +13,31 @@ import io.quarkus.security.identity.SecurityIdentity;
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 jakarta.ws.rs.core.Context;
import org.apache.commons.lang3.NotImplementedException;
import java.security.KeyFactory; import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey; import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.spec.InvalidKeySpecException; import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec; import java.security.spec.PKCS8EncodedKeySpec;
import java.time.ZonedDateTime; import java.time.ZonedDateTime;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
@ApplicationScoped @ApplicationScoped
public class TokenService public class UserTokenService
{ {
@Inject @Inject
CodeRepo codeRepo; CodeRepo codeRepo;
@Inject @Inject
TokenGenerator tokenGenerator; UserTokenGenerator userTokenGenerator;
@Inject @Inject
SecurityIdentity identity; SecurityIdentity identity;
@Inject @Inject
RealmRepo realmRepo; RealmService realmService;
@POST public TokenResponse getToken(String realmKey, String code) throws NoSuchAlgorithmException, InvalidKeySpecException
public TokenResponse getClientToken(String realmKey)
{
return null;
}
public TokenResponse getUserToken(String realmKey, String code) throws NoSuchAlgorithmException, InvalidKeySpecException
{ {
return generateUserToken(realmKey, code); return generateUserToken(realmKey, code);
} }
@ -53,18 +45,17 @@ public class TokenService
private TokenResponse generateUserToken(String realmKey, String code) throws NoSuchAlgorithmException, InvalidKeySpecException private TokenResponse generateUserToken(String realmKey, String code) throws NoSuchAlgorithmException, InvalidKeySpecException
{ {
String principal = identity.getPrincipal().getName(); String principal = identity.getPrincipal().getName();
RealmEntity realm = realmRepo.findById(realmKey); RealmEntity realm = realmService.requireByKey(realmKey);
CodeEntity entity = codeRepo.findByRealmAndId(realm, code); CodeEntity entity = codeRepo.findByRealmAndId(realm, code);
if (entity != null && !ZonedDateTime.now().isAfter(entity.getExpiresAt()) && principal.equals(entity.getClient().getId())) if (entity != null && !ZonedDateTime.now().isAfter(entity.getExpiresAt()) && principal.equals(entity.getClient().getId()))
{ {
KeypairEntity keypair = realm.getKeys().getFirst(); KeypairEntity keypair = realm.getKeys().getFirst();
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keypair.getPrivateKey()); PrivateKey signingKey = KeypairEntity.toPrivateKey(keypair);
ZonedDateTime expiresAt = ZonedDateTime.now().plusYears(1); ZonedDateTime expiresAt = ZonedDateTime.now().plusYears(1);
PrivateKey signingKey = KeyFactory.getInstance(RealmService.EC).generatePrivate(spec);
TokenResponse response = new TokenResponse() TokenResponse response = new TokenResponse()
.setAccessToken(tokenGenerator.generateAccessToken(realm.getKey(), principal, entity.getAccount().getId(), expiresAt, signingKey, keypair.getId())) .setAccessToken(userTokenGenerator.generateAccessToken(realm.getKey(), principal, entity.getAccount().getId(), expiresAt, signingKey, keypair.getId()))
.setRefreshToken(UUID.randomUUID().toString()) .setRefreshToken(UUID.randomUUID().toString())
.setIdToken(tokenGenerator.generateIDToken(realm.getKey(), principal, entity.getAccount().getId(), expiresAt, signingKey, keypair.getId())) .setIdToken(userTokenGenerator.generateIDToken(realm.getKey(), principal, entity.getAccount().getId(), expiresAt, signingKey, keypair.getId()))
.setTokenType("Bearer") .setTokenType("Bearer")
.setExpiresAt(expiresAt.toInstant().getEpochSecond()); .setExpiresAt(expiresAt.toInstant().getEpochSecond());
codeRepo.delete(entity); codeRepo.delete(entity);

View File

@ -0,0 +1,34 @@
package de.tavolio.realm;
import de.tavolio.Role;
import de.tavolio.realm.user.Permission;
import io.quarkus.security.ForbiddenException;
import io.quarkus.security.identity.SecurityIdentity;
import jakarta.enterprise.context.RequestScoped;
import jakarta.inject.Inject;
import java.util.Set;
@RequestScoped
public class PermissionService
{
@Inject
SecurityIdentity identity;
public void hasPermission(Permission... required)
{
if (identity.hasRole(Role.ROOT.toString()))
{
return;
}
Set<Permission> granted = identity.getAttribute("permissions");
for (Permission permission : required)
{
if (granted != null && granted.contains(permission))
{
return;
}
}
throw new ForbiddenException();
}
}

View File

@ -0,0 +1,5 @@
package de.tavolio.realm;
public record Realm(String key, String name)
{
}

View File

@ -9,6 +9,12 @@ import jakarta.ws.rs.Path;
@Path("/realms/{realm-key}") @Path("/realms/{realm-key}")
public class RealmResource public class RealmResource
{ {
@Path("/")
public RealmSubResource realms()
{
return CDI.current().select(RealmSubResource.class).get();
}
@Path("/accounts") @Path("/accounts")
public UserResource accounts() public UserResource accounts()
{ {

View File

@ -0,0 +1,25 @@
package de.tavolio.realm;
import jakarta.enterprise.context.RequestScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.PathParam;
@RequestScoped
public class RealmSubResource
{
@Inject
RealmRepo repo;
@GET
public Realm get(@PathParam("realm-key") String key)
{
RealmEntity entity = repo.findById(key);
if (entity != null)
{
return new Realm(entity.getKey(), entity.getName());
}
throw new NotFoundException();
}
}

View File

@ -3,9 +3,13 @@ package de.tavolio.realm.client;
import de.tavolio.realm.RealmEntity; import de.tavolio.realm.RealmEntity;
import de.tavolio.realm.RealmScoped; import de.tavolio.realm.RealmScoped;
import de.tavolio.realm.code.CodeEntity; import de.tavolio.realm.code.CodeEntity;
import de.tavolio.realm.user.Permission;
import jakarta.persistence.*; import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Set;
@Entity @Entity
@Table(name = "client") @Table(name = "client")
@ -26,6 +30,14 @@ public class ClientEntity implements RealmScoped
@OneToMany(mappedBy = "client") @OneToMany(mappedBy = "client")
private List<CodeEntity> codes; private List<CodeEntity> codes;
@ElementCollection
@CollectionTable(
name = "client_permission",
joinColumns = @JoinColumn(name = "client_id")
)
@Column(name = "permission")
private Set<Permission> permissions = new HashSet<>();
public String getId() public String getId()
{ {
return id; return id;
@ -80,4 +92,15 @@ public class ClientEntity implements RealmScoped
this.codes = codes; this.codes = codes;
return this; return this;
} }
public Set<Permission> getPermissions()
{
return permissions;
}
public ClientEntity setPermissions(Set<Permission> permissions)
{
this.permissions = permissions;
return this;
}
} }

View File

@ -1,5 +1,6 @@
package de.tavolio.realm.client; package de.tavolio.realm.client;
import de.tavolio.bootstrap.Credentials;
import de.tavolio.bootstrap.model.Client; import de.tavolio.bootstrap.model.Client;
import de.tavolio.realm.RealmEntity; import de.tavolio.realm.RealmEntity;
import de.tavolio.realm.RealmRepo; import de.tavolio.realm.RealmRepo;
@ -21,16 +22,6 @@ public class ClientService
@Inject @Inject
RealmRepo realmRepo; RealmRepo realmRepo;
public ClientEntity findOrCreate(RealmEntity realm, Map.Entry<String, Client> bootstrap)
{
String secret = null;
if (!StringUtils.isBlank(bootstrap.getValue().clientSecret()))
{
secret = BcryptUtil.bcryptHash(System.getenv(bootstrap.getValue().clientSecret()));
}
return clientRepo.findByRealmAndIdOptional(realm, bootstrap.getKey()).orElse(new ClientEntity().setId(bootstrap.getKey()).setSecret(secret).setRedirectURI(bootstrap.getValue().redirectURI()).setRealm(realm));
}
public ClientEntity findByIdAndRealm(String clientId, RealmEntity realm) public ClientEntity findByIdAndRealm(String clientId, RealmEntity realm)
{ {
ClientEntity client = clientRepo.findByRealmAndId(realm, clientId); ClientEntity client = clientRepo.findByRealmAndId(realm, clientId);

View File

@ -2,8 +2,15 @@ package de.tavolio.realm.key;
import de.tavolio.realm.RealmEntity; import de.tavolio.realm.RealmEntity;
import de.tavolio.realm.RealmScoped; import de.tavolio.realm.RealmScoped;
import de.tavolio.realm.RealmService;
import jakarta.persistence.*; import jakarta.persistence.*;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
@Entity @Entity
@Table(name = "keypair") @Table(name = "keypair")
public class KeypairEntity implements RealmScoped public class KeypairEntity implements RealmScoped
@ -128,4 +135,17 @@ public class KeypairEntity implements RealmScoped
this.y = y; this.y = y;
return this; return this;
} }
public static PrivateKey toPrivateKey(KeypairEntity keypair)
{
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keypair.getPrivateKey());
try
{
return KeyFactory.getInstance(RealmService.EC).generatePrivate(spec);
}
catch (InvalidKeySpecException | NoSuchAlgorithmException e)
{
throw new RuntimeException(e);
}
}
} }

View File

@ -51,8 +51,8 @@ public class KeypairService
.setUse("sig") .setUse("sig")
.setAlg("ES256") .setAlg("ES256")
.setCrv("P-256") .setCrv("P-256")
.setX(Base64.getUrlEncoder().withoutPadding().encodeToString(xBytes)) .setX(Base64.getEncoder().withoutPadding().encodeToString(xBytes))
.setY(Base64.getUrlEncoder().withoutPadding().encodeToString(yBytes)); .setY(Base64.getEncoder().withoutPadding().encodeToString(yBytes));
} }
private KeyPair generate() private KeyPair generate()

View File

@ -0,0 +1,6 @@
package de.tavolio.realm.user;
public enum Permission
{
USER_VIEW, USER_CREATE
}

View File

@ -1,5 +1,6 @@
package de.tavolio.realm.user; package de.tavolio.realm.user;
import de.tavolio.realm.PermissionService;
import de.tavolio.realm.user.dto.User; import de.tavolio.realm.user.dto.User;
import de.tavolio.realm.user.dto.UserCreation; import de.tavolio.realm.user.dto.UserCreation;
import jakarta.enterprise.context.RequestScoped; import jakarta.enterprise.context.RequestScoped;
@ -20,27 +21,34 @@ public class UserResource
@Inject @Inject
Logger LOG; Logger LOG;
@PathParam("realm-key")
String realmKey;
@Inject @Inject
UserService userService; UserService userService;
@Inject
PermissionService permissionService;
@POST @POST
public User post(@Valid UserCreation account) public User post(@Valid UserCreation account)
{ {
User createdUser = userService.create("", account); permissionService.hasPermission(Permission.USER_CREATE);
LOG.infof("Created account successfully: %s", account.email()); return userService.create(realmKey, account);
return createdUser;
} }
@GET @GET
public User get(@PathParam("id") String id) public List<User> get()
{ {
return userService.getUser(id); permissionService.hasPermission(Permission.USER_VIEW);
return userService.get();
} }
@GET @GET
@Path("/{id}") @Path("/{id}")
public User getById(@PathParam("id") String id) public User getById(@PathParam("id") String id)
{ {
permissionService.hasPermission(Permission.USER_VIEW);
return userService.getUser(id); return userService.getUser(id);
} }
@ -48,6 +56,7 @@ public class UserResource
@Path("/search") @Path("/search")
public Map<String, User> get(List<String> ids) public Map<String, User> get(List<String> ids)
{ {
permissionService.hasPermission(Permission.USER_VIEW);
return userService.findByIds(ids); return userService.findByIds(ids);
} }
} }

View File

@ -7,6 +7,7 @@ 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 java.util.LinkedList; import java.util.LinkedList;
import java.util.List; import java.util.List;
@ -18,44 +19,23 @@ public class JwksService
@Inject @Inject
KeypairRepo keypairRepo; KeypairRepo keypairRepo;
public Optional<JwksKey> findByKid(String kid) public JwksKey findByKid(KeypairEntity keypair)
{ {
KeypairEntity keypair = keypairRepo.findById(kid); switch (keypair.getType())
if (keypair != null)
{ {
switch (keypair.getType()) case "EC" ->
{ {
case "EC" -> return constructPublicKey(keypair);
{ }
return Optional.of(constructPublicKey(keypair)); case "RSA" ->
} {
case "RSA" -> throw new NotImplementedException();
{ }
throw new IllegalArgumentException(); default ->
} {
throw new IllegalArgumentException();
} }
} }
return Optional.empty();
}
public List<JwksKey> findByRealm(RealmEntity realm)
{
List<JwksKey> result = new LinkedList<>();
for (KeypairEntity keypair : realm.getKeys())
{
switch (keypair.getType())
{
case "EC" ->
{
result.add(constructPublicKey(keypair));
}
case "RSA" ->
{
throw new IllegalArgumentException();
}
}
}
return result;
} }
private EcPublicKey constructPublicKey(KeypairEntity entity) private EcPublicKey constructPublicKey(KeypairEntity entity)

View File

@ -1,81 +0,0 @@
package de.tavolio.verify;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.tavolio.verify.jwks.JwksKey;
import io.quarkus.security.AuthenticationFailedException;
import io.quarkus.security.identity.AuthenticationRequestContext;
import io.quarkus.security.identity.IdentityProvider;
import io.quarkus.security.identity.SecurityIdentity;
import io.quarkus.security.identity.request.TokenAuthenticationRequest;
import io.quarkus.security.runtime.QuarkusPrincipal;
import io.quarkus.security.runtime.QuarkusSecurityIdentity;
import io.smallrye.mutiny.Uni;
import io.smallrye.mutiny.infrastructure.Infrastructure;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.context.control.ActivateRequestContext;
import jakarta.inject.Inject;
import java.security.PublicKey;
import java.util.Base64;
import java.util.Map;
import java.util.Optional;
@ApplicationScoped
public class TokenVerifier implements IdentityProvider<TokenAuthenticationRequest>
{
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
@Inject
JwksService jwksService;
@Override
public Class<TokenAuthenticationRequest> getRequestType()
{
return TokenAuthenticationRequest.class;
}
@Override
@ActivateRequestContext
public Uni<SecurityIdentity> authenticate(TokenAuthenticationRequest tokenAuthenticationRequest, AuthenticationRequestContext authenticationRequestContext)
{
String raw = tokenAuthenticationRequest.getToken().getToken();
try
{
Object kid = getHeader(raw).get("kid");
if (kid instanceof String keyId)
{
return Uni.createFrom().item(() -> {
Optional<JwksKey> keypair = jwksService.findByKid(keyId);
if (keypair.isPresent())
{
PublicKey publicKey = keypair.get().toPublicKey();
return (SecurityIdentity) QuarkusSecurityIdentity.builder().setPrincipal(new QuarkusPrincipal("")).build();
}
throw new AuthenticationFailedException();
}).runSubscriptionOn(Infrastructure.getDefaultWorkerPool());
}
return Uni.createFrom().nullItem();
}
catch (JsonProcessingException e)
{
return Uni.createFrom().failure(new AuthenticationFailedException());
}
}
private Map<String, Object> getHeader(String raw) throws JsonProcessingException
{
return OBJECT_MAPPER.readValue(new String(Base64.getDecoder().decode(section(raw))), new TypeReference<Map<String, Object>>(){});
}
private String section(String raw)
{
String[] sections = raw.split("\\.");
if (sections.length == 3)
{
return sections[0];
}
throw new RuntimeException();
}
}

View File

@ -6,15 +6,6 @@ quarkus.http.test-port=9089
quarkus.http.cors.enabled=true quarkus.http.cors.enabled=true
%dev.quarkus.http.cors.origins=/.*/ %dev.quarkus.http.cors.origins=/.*/
# JWT
%prod.smallrye.jwt.sign.key.location=${PRIVATE_KEY_LOCATION}
%prod.mp.jwt.verify.publickey.location=${PUBLIC_KEY_LOCATION}
mp.jwt.verify.issuer=https://tavolio.de
%dev.smallrye.jwt.verify.key.location=/home/andreas/Documents/dev/publicKey.pem
%dev.smallrye.jwt.sign.key.location=/home/andreas/Documents/dev/privateKey.pem
# Postgres # Postgres
prod.quarkus.hibernate-orm.validate-in-dev-mode=false prod.quarkus.hibernate-orm.validate-in-dev-mode=false
quarkus.hibernate-orm.schema-management.strategy=none quarkus.hibernate-orm.schema-management.strategy=none
@ -25,10 +16,6 @@ quarkus.datasource.db-kind=postgresql
%dev,test.quarkus.datasource.password=postgres %dev,test.quarkus.datasource.password=postgres
%dev,test.quarkus.datasource.jdbc.url=jdbc:postgresql://localhost:5432/postgres?currentSchema=auth %dev,test.quarkus.datasource.jdbc.url=jdbc:postgresql://localhost:5432/postgres?currentSchema=auth
%prod.quarkus.datasource.username=${DB_USER}
%prod.quarkus.datasource.password=${DB_PASSWORD}
%prod.quarkus.datasource.jdbc.url=jdbc:postgresql://${DB_HOST}:${DB_PORT}/${DB_DATABASE}?currentSchema=${DB_SCHEMA}
# Flyway # Flyway
quarkus.flyway.enabled=false quarkus.flyway.enabled=false
%test.quarkus.flyway.clean-at-start=true %test.quarkus.flyway.clean-at-start=true
@ -37,16 +24,10 @@ quarkus.flyway.enabled=false
%test,dev.quarkus.flyway.migrate-at-start=false %test,dev.quarkus.flyway.migrate-at-start=false
quarkus.flyway.migrate-at-start=true quarkus.flyway.migrate-at-start=true
# IAM Superuser
%test,dev.iam.user.name=tavolio
%test,dev.iam.user.password=tavolio
quarkus.http.access-log.enabled=true quarkus.http.access-log.enabled=true
quarkus.http.auth.basic=true quarkus.http.auth.basic=true
dev.dinauer.idp.origin=http://localhost:8089
%dev.dev.dinauer.idp.superuser.username=admin io.verifoo.http.origin=http://localhost:8089
%dev.dev.dinauer.idp.superuser.password=pw
quarkus.log.level=DEBUG %dev.io.verifoo.superuser.username=admin
%dev.io.verifoo.superuser.password=pw

View File

@ -1,22 +1,22 @@
realms: realms:
maven: maven:
name: My Bootstrap Realm name: MavenVault
audience: audience:
strategy: realm strategy: static
value: https://maven.dinauer.dev/api
key: key:
type: EC type: EC
alg: P256 alg: P256
clients: clients:
backend: backend:
client-secret: MY_SECRET_ENV secret:
permissions: bcrypt: $2a$12$1oYS45e/nXP1OeMgdZZAKeEixarRDzbBGZd0xOnEQQMKlOKwVMrX.
- USER:VIEW
frontend:
redirect-uri: http://localhost:8080/callback redirect-uri: http://localhost:8080/callback
accounts: permissions:
- USER_VIEW
users:
- email: andreas.j.dinauer@gmail.com - email: andreas.j.dinauer@gmail.com
first-name: Andreas first-name: Andreas
last-name: Dinauer last-name: Dinauer
password-plain: pw password:
roles: bcrypt: $2a$12$vvHIfs6MWovIulGcuHeDie7yKtZzkBEPpNPiuj3C2HmUASYQ/x5SO
- USER:VIEW