Ory Hydra OAuth2 in Java: Comprehensive OAuth2 and OpenID Connect Implementation

Ory Hydra is a hardened, OpenID Certified OAuth 2.0 server and OpenID Connect Provider. This guide covers complete integration with Java applications for authentication and authorization flows.


Core Concepts

What is Ory Hydra?

  • OAuth 2.0 and OpenID Connect server
  • Decoupled architecture - handles OAuth flows, you handle login/consent
  • Production-ready with security best practices
  • Supports all OAuth2 flows and OpenID Connect

Key Benefits:

  • Security-First: Built with security best practices
  • Scalable: Handles high-volume authentication flows
  • Standards Compliant: OpenID Certified
  • Flexible: Decoupled architecture for custom UIs

Architecture Overview

Client Application
↓
Ory Hydra (OAuth2 Server)
↓
Login & Consent Provider (Your Java App)
↓
User Store / Identity Provider

Dependencies and Setup

Maven Dependencies
<properties>
<spring-boot.version>3.1.0</spring-boot.version>
<spring-security.version>6.1.0</spring-security.version>
<hydra-client.version>2.2.0</hydra-client.version>
<nimbus-jose-jwt.version>9.31</nimbus-jose-jwt.version>
</properties>
<dependencies>
<!-- Spring Boot -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<!-- Ory Hydra Client -->
<dependency>
<groupId>sh.ory.hydra</groupId>
<artifactId>hydra-client</artifactId>
<version>${hydra-client.version}</version>
</dependency>
<!-- JWT Support -->
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
<version>${nimbus-jose-jwt.version}</version>
</dependency>
<!-- Database -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Redis for Session Management -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<!-- Testing -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<version>${spring-boot.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<version>${spring-security.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
Docker Compose for Hydra
# docker-compose.yml
version: '3.8'
services:
hydra:
image: oryd/hydra:v2.2.0
ports:
- "4444:4444"  # Public port
- "4445:4445"  # Admin port
command:
- serve
- all
- --dev
environment:
- DSN=memory
- URLS_SELF_ISSUER=http://localhost:4444
- URLS_CONSENT=http://localhost:8080/consent
- URLS_LOGIN=http://localhost:8080/login
depends_on:
- postgres
postgres:
image: postgres:15
environment:
- POSTGRES_DB=hydra
- POSTGRES_USER=hydra
- POSTGRES_PASSWORD=secret
- POSTGRES_HOST_AUTH_METHOD=trust
ports:
- "5432:5432"
redis:
image: redis:7-alpine
ports:
- "6379:6379"
Application Configuration
# application.yml
spring:
application:
name: hydra-oauth-provider
datasource:
url: jdbc:postgresql://localhost:5432/hydra
username: hydra
password: secret
driver-class-name: org.postgresql.Driver
jpa:
hibernate:
ddl-auto: validate
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
redis:
host: localhost
port: 6379
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:4444/
app:
hydra:
public-url: http://localhost:4444
admin-url: http://localhost:4445
client:
id: auth-service
secret: auth-secret
cors:
allowed-origins:
- http://localhost:3000
- http://localhost:8080
server:
port: 8080
logging:
level:
sh.ory.hydra: DEBUG
com.example.hydra: DEBUG

Core Implementation

1. Configuration Classes
@Configuration
@ConfigurationProperties(prefix = "app.hydra")
@Data
@Validated
public class HydraConfig {
@NotBlank
private String publicUrl = "http://localhost:4444";
@NotBlank
private String adminUrl = "http://localhost:4445";
private ClientConfig client = new ClientConfig();
private CorsConfig cors = new CorsConfig();
@Data
public static class ClientConfig {
@NotBlank
private String id = "auth-service";
@NotBlank
private String secret = "auth-secret";
}
@Data
public static class CorsConfig {
private List<String> allowedOrigins = Arrays.asList(
"http://localhost:3000",
"http://localhost:8080"
);
}
}
@Configuration
@EnableConfigurationProperties(HydraConfig.class)
public class HydraClientConfig {
@Bean
public ApiClient adminApiClient(HydraConfig hydraConfig) {
ApiClient adminClient = new ApiClient();
adminClient.setBasePath(hydraConfig.getAdminUrl());
return adminClient;
}
@Bean
public AdminApi adminApi(ApiClient adminApiClient) {
return new AdminApi(adminApiClient);
}
@Bean
public PublicApi publicApi(HydraConfig hydraConfig) {
ApiClient publicClient = new ApiClient();
publicClient.setBasePath(hydraConfig.getPublicUrl());
return new PublicApi(publicClient);
}
}
2. Data Models
@Entity
@Table(name = "users")
@Data
public class User {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private String id;
@Column(unique = true, nullable = false)
private String email;
@Column(nullable = false)
private String password;
private String firstName;
private String lastName;
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "user_roles", joinColumns = @JoinColumn(name = "user_id"))
@Column(name = "role")
private Set<String> roles = new HashSet<>();
private boolean enabled = true;
private boolean accountNonExpired = true;
private boolean credentialsNonExpired = true;
private boolean accountNonLocked = true;
@CreationTimestamp
private Instant createdAt;
@UpdateTimestamp
private Instant updatedAt;
}
@Entity
@Table(name = "oauth_clients")
@Data
public class OAuthClient {
@Id
private String clientId;
@Column(nullable = false)
private String clientName;
private String clientSecret;
@ElementCollection
@CollectionTable(name = "client_redirect_uris", joinColumns = @JoinColumn(name = "client_id"))
@Column(name = "redirect_uri")
private Set<String> redirectUris = new HashSet<>();
@ElementCollection
@CollectionTable(name = "client_grant_types", joinColumns = @JoinColumn(name = "client_id"))
@Column(name = "grant_type")
private Set<String> grantTypes = new HashSet<>();
@ElementCollection
@CollectionTable(name = "client_response_types", joinColumns = @JoinColumn(name = "client_id"))
@Column(name = "response_type")
private Set<String> responseTypes = new HashSet<>();
@ElementCollection
@CollectionTable(name = "client_scopes", joinColumns = @JoinColumn(name = "client_id"))
@Column(name = "scope")
private Set<String> scopes = new HashSet<>();
private String owner;
private String policyUri;
private String tosUri;
private String clientUri;
private String logoUri;
private String contacts;
private boolean publicClient = false;
private boolean skipConsent = false;
@CreationTimestamp
private Instant createdAt;
@UpdateTimestamp
private Instant updatedAt;
}
@Entity
@Table(name = "login_sessions")
@Data
public class LoginSession {
@Id
private String challenge;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
@Column(nullable = false)
private boolean remember = false;
@Column(nullable = false)
private Instant requestedAt;
private Instant authenticatedAt;
@Enumerated(EnumType.STRING)
private SessionStatus status = SessionStatus.PENDING;
private String error;
private String errorDescription;
private String errorHint;
private String errorDebug;
public enum SessionStatus {
PENDING, AUTHENTICATED, REJECTED, EXPIRED
}
}
@Entity
@Table(name = "consent_sessions")
@Data
public class ConsentSession {
@Id
private String challenge;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
@Column(nullable = false)
private String clientId;
@ElementCollection
@CollectionTable(name = "consent_granted_scopes", joinColumns = @JoinColumn(name = "challenge"))
@Column(name = "scope")
private Set<String> grantedScopes = new HashSet<>();
@ElementCollection
@CollectionTable(name = "consent_granted_claims", joinColumns = @JoinColumn(name = "challenge"))
@Column(name = "claim")
private Set<String> grantedClaims = new HashSet<>();
@Column(nullable = false)
private Instant requestedAt;
private Instant handledAt;
@Enumerated(EnumType.STRING)
private SessionStatus status = SessionStatus.PENDING;
private boolean remember = false;
private Integer rememberFor = 0;
public enum SessionStatus {
PENDING, GRANTED, DENIED, EXPIRED
}
}
3. Hydra Service
@Service
@Slf4j
public class HydraService {
private final AdminApi adminApi;
private final PublicApi publicApi;
private final HydraConfig hydraConfig;
public HydraService(AdminApi adminApi, PublicApi publicApi, HydraConfig hydraConfig) {
this.adminApi = adminApi;
this.publicApi = publicApi;
this.hydraConfig = hydraConfig;
}
// Login Flow Methods
public OAuth2LoginRequest getLoginRequest(String challenge) throws HydraException {
try {
return adminApi.getLoginRequest(challenge);
} catch (ApiException e) {
log.error("Failed to get login request for challenge: {}", challenge, e);
throw new HydraException("Failed to get login request", e);
}
}
public OAuth2RedirectTo acceptLoginRequest(String challenge, AcceptLoginRequest request) throws HydraException {
try {
return adminApi.acceptLoginRequest(challenge, request);
} catch (ApiException e) {
log.error("Failed to accept login request for challenge: {}", challenge, e);
throw new HydraException("Failed to accept login request", e);
}
}
public OAuth2RedirectTo rejectLoginRequest(String challenge, RejectRequest request) throws HydraException {
try {
return adminApi.rejectLoginRequest(challenge, request);
} catch (ApiException e) {
log.error("Failed to reject login request for challenge: {}", challenge, e);
throw new HydraException("Failed to reject login request", e);
}
}
// Consent Flow Methods
public OAuth2ConsentRequest getConsentRequest(String challenge) throws HydraException {
try {
return adminApi.getConsentRequest(challenge);
} catch (ApiException e) {
log.error("Failed to get consent request for challenge: {}", challenge, e);
throw new HydraException("Failed to get consent request", e);
}
}
public OAuth2RedirectTo acceptConsentRequest(String challenge, AcceptConsentRequest request) throws HydraException {
try {
return adminApi.acceptConsentRequest(challenge, request);
} catch (ApiException e) {
log.error("Failed to accept consent request for challenge: {}", challenge, e);
throw new HydraException("Failed to accept consent request", e);
}
}
public OAuth2RedirectTo rejectConsentRequest(String challenge, RejectRequest request) throws HydraException {
try {
return adminApi.rejectConsentRequest(challenge, request);
} catch (ApiException e) {
log.error("Failed to reject consent request for challenge: {}", challenge, e);
throw new HydraException("Failed to reject consent request", e);
}
}
// Logout Flow Methods
public OAuth2LogoutRequest getLogoutRequest(String challenge) throws HydraException {
try {
return adminApi.getLogoutRequest(challenge);
} catch (ApiException e) {
log.error("Failed to get logout request for challenge: {}", challenge, e);
throw new HydraException("Failed to get logout request", e);
}
}
public OAuth2RedirectTo acceptLogoutRequest(String challenge) throws HydraException {
try {
return adminApi.acceptLogoutRequest(challenge);
} catch (ApiException e) {
log.error("Failed to accept logout request for challenge: {}", challenge, e);
throw new HydraException("Failed to accept logout request", e);
}
}
// Client Management
public OAuth2Client createOAuth2Client(OAuth2Client client) throws HydraException {
try {
return adminApi.createOAuth2Client(client);
} catch (ApiException e) {
log.error("Failed to create OAuth2 client", e);
throw new HydraException("Failed to create OAuth2 client", e);
}
}
public OAuth2Client getOAuth2Client(String clientId) throws HydraException {
try {
return adminApi.getOAuth2Client(clientId);
} catch (ApiException e) {
log.error("Failed to get OAuth2 client: {}", clientId, e);
throw new HydraException("Failed to get OAuth2 client", e);
}
}
public List<OAuth2Client> listOAuth2Clients() throws HydraException {
try {
return adminApi.listOAuth2Clients();
} catch (ApiException e) {
log.error("Failed to list OAuth2 clients", e);
throw new HydraException("Failed to list OAuth2 clients", e);
}
}
public OAuth2Client updateOAuth2Client(String clientId, OAuth2Client client) throws HydraException {
try {
return adminApi.updateOAuth2Client(clientId, client);
} catch (ApiException e) {
log.error("Failed to update OAuth2 client: {}", clientId, e);
throw new HydraException("Failed to update OAuth2 client", e);
}
}
public void deleteOAuth2Client(String clientId) throws HydraException {
try {
adminApi.deleteOAuth2Client(clientId);
} catch (ApiException e) {
log.error("Failed to delete OAuth2 client: {}", clientId, e);
throw new HydraException("Failed to delete OAuth2 client", e);
}
}
// Token Introspection
public OAuth2TokenIntrospection introspectToken(String token, String scope) throws HydraException {
try {
return adminApi.introspectOAuth2Token(token, scope);
} catch (ApiException e) {
log.error("Failed to introspect token", e);
throw new HydraException("Failed to introspect token", e);
}
}
// User Info
public Map<String, Object> getUserInfo(String token) throws HydraException {
try {
// This would typically call the userinfo endpoint
// For now, we'll use introspection and add user claims
OAuth2TokenIntrospection introspection = introspectToken(token, null);
if (!introspection.getActive()) {
throw new HydraException("Token is not active");
}
Map<String, Object> userInfo = new HashMap<>();
userInfo.put("sub", introspection.getSub());
userInfo.put("active", introspection.getActive());
userInfo.put("scope", introspection.getScope());
userInfo.put("client_id", introspection.getClientId());
userInfo.put("exp", introspection.getExp());
userInfo.put("iat", introspection.getIat());
return userInfo;
} catch (ApiException e) {
log.error("Failed to get user info", e);
throw new HydraException("Failed to get user info", e);
}
}
// Utility Methods
public boolean isLoginChallengeValid(String challenge) {
try {
OAuth2LoginRequest request = getLoginRequest(challenge);
return request != null && !request.getSkip();
} catch (HydraException e) {
return false;
}
}
public boolean isConsentChallengeValid(String challenge) {
try {
OAuth2ConsentRequest request = getConsentRequest(challenge);
return request != null;
} catch (HydraException e) {
return false;
}
}
public AcceptLoginRequest createAcceptLoginRequest(String subject, boolean remember, Instant rememberFor) {
AcceptLoginRequest request = new AcceptLoginRequest();
request.setSubject(subject);
request.setRemember(remember);
if (rememberFor != null) {
request.setRememberFor(rememberFor.getEpochSecond());
}
return request;
}
public AcceptConsentRequest createAcceptConsentRequest(String subject, List<String> grantScopes, 
Map<String, Object> session, boolean remember) {
AcceptConsentRequest request = new AcceptConsentRequest();
request.setGrantScope(grantScopes);
request.setGrantAccessTokenAudience(null); // Use requested audiences
request.setSession(session);
request.setRemember(remember);
request.setRememberFor(3600L); // 1 hour
return request;
}
}
4. User Service
@Service
@Slf4j
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
public Optional<User> findByEmail(String email) {
return userRepository.findByEmail(email);
}
public Optional<User> findById(String id) {
return userRepository.findById(id);
}
public User createUser(UserRegistrationRequest request) {
if (userRepository.findByEmail(request.getEmail()).isPresent()) {
throw new UserAlreadyExistsException("User with email already exists: " + request.getEmail());
}
User user = new User();
user.setEmail(request.getEmail());
user.setPassword(passwordEncoder.encode(request.getPassword()));
user.setFirstName(request.getFirstName());
user.setLastName(request.getLastName());
user.setRoles(new HashSet<>(request.getRoles()));
return userRepository.save(user);
}
public User updateUser(String userId, UserUpdateRequest request) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException("User not found: " + userId));
if (request.getFirstName() != null) {
user.setFirstName(request.getFirstName());
}
if (request.getLastName() != null) {
user.setLastName(request.getLastName());
}
if (request.getRoles() != null) {
user.setRoles(new HashSet<>(request.getRoles()));
}
return userRepository.save(user);
}
public void deleteUser(String userId) {
if (!userRepository.existsById(userId)) {
throw new UserNotFoundException("User not found: " + userId);
}
userRepository.deleteById(userId);
}
public boolean validateCredentials(String email, String password) {
return userRepository.findByEmail(email)
.map(user -> passwordEncoder.matches(password, user.getPassword()))
.orElse(false);
}
public Map<String, Object> getUserClaims(User user) {
Map<String, Object> claims = new HashMap<>();
claims.put("sub", user.getId());
claims.put("email", user.getEmail());
claims.put("email_verified", true);
claims.put("name", user.getFirstName() + " " + user.getLastName());
claims.put("given_name", user.getFirstName());
claims.put("family_name", user.getLastName());
claims.put("roles", user.getRoles());
return claims;
}
}
@Data
class UserRegistrationRequest {
@NotBlank
@Email
private String email;
@NotBlank
@Size(min = 8)
private String password;
@NotBlank
private String firstName;
@NotBlank
private String lastName;
private List<String> roles = Arrays.asList("ROLE_USER");
}
@Data
class UserUpdateRequest {
private String firstName;
private String lastName;
private List<String> roles;
}
5. Login and Consent Service
@Service
@Slf4j
public class LoginConsentService {
private final HydraService hydraService;
private final UserService userService;
private final LoginSessionRepository loginSessionRepository;
private final ConsentSessionRepository consentSessionRepository;
public LoginConsentService(HydraService hydraService, UserService userService,
LoginSessionRepository loginSessionRepository,
ConsentSessionRepository consentSessionRepository) {
this.hydraService = hydraService;
this.userService = userService;
this.loginSessionRepository = loginSessionRepository;
this.consentSessionRepository = consentSessionRepository;
}
// Login Flow
public LoginFlowData getLoginFlowData(String challenge) throws HydraException {
OAuth2LoginRequest loginRequest = hydraService.getLoginRequest(challenge);
LoginSession session = loginSessionRepository.findById(challenge)
.orElse(new LoginSession());
session.setChallenge(challenge);
session.setRequestedAt(Instant.now());
LoginFlowData flowData = new LoginFlowData();
flowData.setChallenge(challenge);
flowData.setRequestedScopes(loginRequest.getRequestedScope() != null ? 
Arrays.asList(loginRequest.getRequestedScope().split(" ")) : Collections.emptyList());
flowData.setClient(loginRequest.getClient());
flowData.setSkip(loginRequest.getSkip());
flowData.setSubject(loginRequest.getSubject());
flowData.setSession(session);
return flowData;
}
public String processLogin(String challenge, String email, String password, boolean remember) 
throws HydraException {
// Validate credentials
if (!userService.validateCredentials(email, password)) {
throw new AuthenticationException("Invalid credentials");
}
User user = userService.findByEmail(email)
.orElseThrow(() -> new AuthenticationException("User not found"));
// Update login session
LoginSession session = loginSessionRepository.findById(challenge)
.orElse(new LoginSession());
session.setChallenge(challenge);
session.setUser(user);
session.setRemember(remember);
session.setAuthenticatedAt(Instant.now());
session.setStatus(LoginSession.SessionStatus.AUTHENTICATED);
loginSessionRepository.save(session);
// Accept login with Hydra
AcceptLoginRequest acceptRequest = hydraService.createAcceptLoginRequest(
user.getId(), remember, Instant.now().plus(30, ChronoUnit.DAYS));
OAuth2RedirectTo redirect = hydraService.acceptLoginRequest(challenge, acceptRequest);
return redirect.getRedirectTo();
}
public String rejectLogin(String challenge, String error, String errorDescription) 
throws HydraException {
LoginSession session = loginSessionRepository.findById(challenge)
.orElse(new LoginSession());
session.setChallenge(challenge);
session.setStatus(LoginSession.SessionStatus.REJECTED);
session.setError(error);
session.setErrorDescription(errorDescription);
loginSessionRepository.save(session);
RejectRequest rejectRequest = new RejectRequest();
rejectRequest.setError(error);
rejectRequest.setErrorDescription(errorDescription);
OAuth2RedirectTo redirect = hydraService.rejectLoginRequest(challenge, rejectRequest);
return redirect.getRedirectTo();
}
// Consent Flow
public ConsentFlowData getConsentFlowData(String challenge) throws HydraException {
OAuth2ConsentRequest consentRequest = hydraService.getConsentRequest(challenge);
ConsentSession session = consentSessionRepository.findById(challenge)
.orElse(new ConsentSession());
session.setChallenge(challenge);
session.setClientId(consentRequest.getClient().getClientId());
session.setRequestedAt(Instant.now());
// Get user from login session
User user = getUserForConsent(consentRequest);
session.setUser(user);
ConsentFlowData flowData = new ConsentFlowData();
flowData.setChallenge(challenge);
flowData.setRequestedScopes(consentRequest.getRequestedScope() != null ? 
Arrays.asList(consentRequest.getRequestedScope().split(" ")) : Collections.emptyList());
flowData.setClient(consentRequest.getClient());
flowData.setSkip(consentRequest.getSkip());
flowData.setSubject(consentRequest.getSubject());
flowData.setUser(user);
flowData.setSession(session);
return flowData;
}
public String processConsent(String challenge, List<String> grantScopes, boolean remember) 
throws HydraException {
ConsentSession session = consentSessionRepository.findById(challenge)
.orElseThrow(() -> new HydraException("Consent session not found"));
User user = session.getUser();
if (user == null) {
throw new HydraException("User not found for consent session");
}
// Update consent session
session.setGrantedScopes(new HashSet<>(grantScopes));
session.setGrantedClaims(new HashSet<>(Arrays.asList("email", "name")));
session.setRemember(remember);
session.setHandledAt(Instant.now());
session.setStatus(ConsentSession.SessionStatus.GRANTED);
consentSessionRepository.save(session);
// Prepare session data
Map<String, Object> sessionData = new HashMap<>();
sessionData.put("user", userService.getUserClaims(user));
// Accept consent with Hydra
AcceptConsentRequest acceptRequest = hydraService.createAcceptConsentRequest(
user.getId(), grantScopes, sessionData, remember);
OAuth2RedirectTo redirect = hydraService.acceptConsentRequest(challenge, acceptRequest);
return redirect.getRedirectTo();
}
public String rejectConsent(String challenge, String error, String errorDescription) 
throws HydraException {
ConsentSession session = consentSessionRepository.findById(challenge)
.orElse(new ConsentSession());
session.setChallenge(challenge);
session.setStatus(ConsentSession.SessionStatus.DENIED);
session.setError(error);
session.setErrorDescription(errorDescription);
consentSessionRepository.save(session);
RejectRequest rejectRequest = new RejectRequest();
rejectRequest.setError(error);
rejectRequest.setErrorDescription(errorDescription);
OAuth2RedirectTo redirect = hydraService.rejectConsentRequest(challenge, rejectRequest);
return redirect.getRedirectTo();
}
private User getUserForConsent(OAuth2ConsentRequest consentRequest) {
String subject = consentRequest.getSubject();
if (subject != null) {
return userService.findById(subject)
.orElseThrow(() -> new UserNotFoundException("User not found: " + subject));
}
throw new UserNotFoundException("No subject in consent request");
}
}
@Data
class LoginFlowData {
private String challenge;
private List<String> requestedScopes;
private OAuth2Client client;
private boolean skip;
private String subject;
private LoginSession session;
}
@Data
class ConsentFlowData {
private String challenge;
private List<String> requestedScopes;
private OAuth2Client client;
private boolean skip;
private String subject;
private User user;
private ConsentSession session;
}
6. Repository Interfaces
@Repository
public interface UserRepository extends JpaRepository<User, String> {
Optional<User> findByEmail(String email);
boolean existsByEmail(String email);
@Query("SELECT u FROM User u WHERE u.enabled = true")
List<User> findAllEnabled();
}
@Repository
public interface LoginSessionRepository extends JpaRepository<LoginSession, String> {
@Query("SELECT s FROM LoginSession s WHERE s.status = 'PENDING' AND s.requestedAt < :expiryTime")
List<LoginSession> findExpiredSessions(@Param("expiryTime") Instant expiryTime);
@Modifying
@Query("DELETE FROM LoginSession s WHERE s.status = 'EXPIRED'")
void deleteExpiredSessions();
}
@Repository
public interface ConsentSessionRepository extends JpaRepository<ConsentSession, String> {
@Query("SELECT s FROM ConsentSession s WHERE s.status = 'PENDING' AND s.requestedAt < :expiryTime")
List<ConsentSession> findExpiredSessions(@Param("expiryTime") Instant expiryTime);
@Modifying
@Query("DELETE FROM ConsentSession s WHERE s.status = 'EXPIRED'")
void deleteExpiredSessions();
}
@Repository
public interface OAuthClientRepository extends JpaRepository<OAuthClient, String> {
List<OAuthClient> findByPublicClient(boolean publicClient);
@Query("SELECT c FROM OAuthClient c WHERE c.owner = :owner")
List<OAuthClient> findByOwner(@Param("owner") String owner);
}

REST API Controllers

1. Login Controller
@Controller
@RequestMapping("/login")
@Slf4j
public class LoginController {
private final LoginConsentService loginConsentService;
public LoginController(LoginConsentService loginConsentService) {
this.loginConsentService = loginConsentService;
}
@GetMapping
public String showLoginPage(@RequestParam("login_challenge") String challenge, 
Model model) {
try {
LoginFlowData flowData = loginConsentService.getLoginFlowData(challenge);
model.addAttribute("challenge", challenge);
model.addAttribute("client", flowData.getClient());
model.addAttribute("skip", flowData.isSkip());
return "login";
} catch (HydraException e) {
log.error("Failed to get login flow data", e);
model.addAttribute("error", "Invalid login challenge");
return "error";
}
}
@PostMapping
public String processLogin(@RequestParam("login_challenge") String challenge,
@RequestParam String email,
@RequestParam String password,
@RequestParam(defaultValue = "false") boolean remember,
Model model) {
try {
String redirectUrl = loginConsentService.processLogin(challenge, email, password, remember);
return "redirect:" + redirectUrl;
} catch (AuthenticationException e) {
log.warn("Authentication failed for email: {}", email);
model.addAttribute("challenge", challenge);
model.addAttribute("error", "Invalid credentials");
return "login";
} catch (HydraException e) {
log.error("Login processing failed", e);
model.addAttribute("error", "Login processing failed");
return "error";
}
}
@PostMapping("/reject")
public String rejectLogin(@RequestParam("login_challenge") String challenge,
@RequestParam String error,
@RequestParam String errorDescription) {
try {
String redirectUrl = loginConsentService.rejectLogin(challenge, error, errorDescription);
return "redirect:" + redirectUrl;
} catch (HydraException e) {
log.error("Login rejection failed", e);
return "redirect:/error";
}
}
}
2. Consent Controller
@Controller
@RequestMapping("/consent")
@Slf4j
public class ConsentController {
private final LoginConsentService loginConsentService;
public ConsentController(LoginConsentService loginConsentService) {
this.loginConsentService = loginConsentService;
}
@GetMapping
public String showConsentPage(@RequestParam("consent_challenge") String challenge, 
Model model) {
try {
ConsentFlowData flowData = loginConsentService.getConsentFlowData(challenge);
model.addAttribute("challenge", challenge);
model.addAttribute("client", flowData.getClient());
model.addAttribute("requestedScopes", flowData.getRequestedScopes());
model.addAttribute("user", flowData.getUser());
model.addAttribute("skip", flowData.isSkip());
return "consent";
} catch (HydraException e) {
log.error("Failed to get consent flow data", e);
model.addAttribute("error", "Invalid consent challenge");
return "error";
}
}
@PostMapping
public String processConsent(@RequestParam("consent_challenge") String challenge,
@RequestParam List<String> grantScopes,
@RequestParam(defaultValue = "false") boolean remember,
Model model) {
try {
String redirectUrl = loginConsentService.processConsent(challenge, grantScopes, remember);
return "redirect:" + redirectUrl;
} catch (HydraException e) {
log.error("Consent processing failed", e);
model.addAttribute("error", "Consent processing failed");
return "error";
}
}
@PostMapping("/reject")
public String rejectConsent(@RequestParam("consent_challenge") String challenge,
@RequestParam String error,
@RequestParam String errorDescription) {
try {
String redirectUrl = loginConsentService.rejectConsent(challenge, error, errorDescription);
return "redirect:" + redirectUrl;
} catch (HydraException e) {
log.error("Consent rejection failed", e);
return "redirect:/error";
}
}
}
3. OAuth2 API Controller
@RestController
@RequestMapping("/api/oauth2")
@Slf4j
public class OAuth2ApiController {
private final HydraService hydraService;
private final UserService userService;
public OAuth2ApiController(HydraService hydraService, UserService userService) {
this.hydraService = hydraService;
this.userService = userService;
}
@PostMapping("/clients")
public ResponseEntity<OAuth2Client> createClient(@RequestBody @Valid OAuthClientRegistrationRequest request) {
try {
OAuth2Client client = new OAuth2Client();
client.setClientId(request.getClientId());
client.setClientName(request.getClientName());
client.setClientSecret(request.getClientSecret());
client.setRedirectUris(request.getRedirectUris());
client.setGrantTypes(request.getGrantTypes());
client.setResponseTypes(request.getResponseTypes());
client.setScope(String.join(" ", request.getScopes()));
client.setOwner(request.getOwner());
OAuth2Client createdClient = hydraService.createOAuth2Client(client);
return ResponseEntity.status(HttpStatus.CREATED).body(createdClient);
} catch (HydraException e) {
log.error("Failed to create OAuth2 client", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@GetMapping("/clients")
public ResponseEntity<List<OAuth2Client>> listClients() {
try {
List<OAuth2Client> clients = hydraService.listOAuth2Clients();
return ResponseEntity.ok(clients);
} catch (HydraException e) {
log.error("Failed to list OAuth2 clients", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@GetMapping("/clients/{clientId}")
public ResponseEntity<OAuth2Client> getClient(@PathVariable String clientId) {
try {
OAuth2Client client = hydraService.getOAuth2Client(clientId);
return ResponseEntity.ok(client);
} catch (HydraException e) {
log.error("Failed to get OAuth2 client: {}", clientId, e);
return ResponseEntity.notFound().build();
}
}
@PutMapping("/clients/{clientId}")
public ResponseEntity<OAuth2Client> updateClient(@PathVariable String clientId,
@RequestBody OAuthClientUpdateRequest request) {
try {
OAuth2Client client = hydraService.getOAuth2Client(clientId);
if (request.getClientName() != null) {
client.setClientName(request.getClientName());
}
if (request.getRedirectUris() != null) {
client.setRedirectUris(request.getRedirectUris());
}
if (request.getGrantTypes() != null) {
client.setGrantTypes(request.getGrantTypes());
}
OAuth2Client updatedClient = hydraService.updateOAuth2Client(clientId, client);
return ResponseEntity.ok(updatedClient);
} catch (HydraException e) {
log.error("Failed to update OAuth2 client: {}", clientId, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@DeleteMapping("/clients/{clientId}")
public ResponseEntity<Void> deleteClient(@PathVariable String clientId) {
try {
hydraService.deleteOAuth2Client(clientId);
return ResponseEntity.noContent().build();
} catch (HydraException e) {
log.error("Failed to delete OAuth2 client: {}", clientId, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@PostMapping("/introspect")
public ResponseEntity<OAuth2TokenIntrospection> introspectToken(@RequestBody TokenIntrospectionRequest request) {
try {
OAuth2TokenIntrospection introspection = hydraService.introspectToken(
request.getToken(), request.getScope());
return ResponseEntity.ok(introspection);
} catch (HydraException e) {
log.error("Token introspection failed", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@GetMapping("/userinfo")
public ResponseEntity<Map<String, Object>> getUserInfo(@RequestHeader("Authorization") String authorization) {
try {
String token = authorization.replace("Bearer ", "");
Map<String, Object> userInfo = hydraService.getUserInfo(token);
return ResponseEntity.ok(userInfo);
} catch (HydraException e) {
log.error("User info request failed", e);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
}
}
@Data
class OAuthClientRegistrationRequest {
@NotBlank
private String clientId;
@NotBlank
private String clientName;
private String clientSecret;
@NotEmpty
private List<String> redirectUris;
@NotEmpty
private List<String> grantTypes = Arrays.asList("authorization_code", "refresh_token");
@NotEmpty
private List<String> responseTypes = Arrays.asList("code");
@NotEmpty
private List<String> scopes = Arrays.asList("openid", "profile", "email");
private String owner;
}
@Data
class OAuthClientUpdateRequest {
private String clientName;
private List<String> redirectUris;
private List<String> grantTypes;
private List<String> scopes;
}
@Data
class TokenIntrospectionRequest {
@NotBlank
private String token;
private String scope;
}
4. User Management API
@RestController
@RequestMapping("/api/users")
@Slf4j
@Validated
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@PostMapping("/register")
public ResponseEntity<User> registerUser(@RequestBody @Valid UserRegistrationRequest request) {
try {
User user = userService.createUser(request);
return ResponseEntity.status(HttpStatus.CREATED).body(user);
} catch (UserAlreadyExistsException e) {
return ResponseEntity.status(HttpStatus.CONFLICT).build();
}
}
@GetMapping("/{userId}")
public ResponseEntity<User> getUser(@PathVariable String userId) {
return userService.findById(userId)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PutMapping("/{userId}")
public ResponseEntity<User> updateUser(@PathVariable String userId,
@RequestBody @Valid UserUpdateRequest request) {
try {
User user = userService.updateUser(userId, request);
return ResponseEntity.ok(user);
} catch (UserNotFoundException e) {
return ResponseEntity.notFound().build();
}
}
@DeleteMapping("/{userId}")
public ResponseEntity<Void> deleteUser(@PathVariable String userId) {
try {
userService.deleteUser(userId);
return ResponseEntity.noContent().build();
} catch (UserNotFoundException e) {
return ResponseEntity.notFound().build();
}
}
@GetMapping
public ResponseEntity<List<User>> listUsers() {
List<User> users = userService.findAllEnabled();
return ResponseEntity.ok(users);
}
}

Security Configuration

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authz -> authz
.requestMatchers("/login", "/consent", "/error").permitAll()
.requestMatchers("/api/users/register").permitAll()
.requestMatchers("/api/oauth2/introspect", "/api/oauth2/userinfo").authenticated()
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login")
.permitAll()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(Customizer.withDefaults())
)
.csrf(csrf -> csrf
.ignoringRequestMatchers("/api/oauth2/introspect", "/api/oauth2/userinfo")
);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public JwtDecoder jwtDecoder() {
return NimbusJwtDecoder.withIssuerLocation("http://localhost:4444").build();
}
}

Exception Handling

@ControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(HydraException.class)
public ResponseEntity<ErrorResponse> handleHydraException(HydraException e) {
log.error("Hydra operation failed", e);
ErrorResponse error = new ErrorResponse("HYDRA_ERROR", e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
}
@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<ErrorResponse> handleAuthenticationException(AuthenticationException e) {
ErrorResponse error = new ErrorResponse("AUTHENTICATION_ERROR", e.getMessage());
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error);
}
@ExceptionHandler(UserAlreadyExistsException.class)
public ResponseEntity<ErrorResponse> handleUserAlreadyExistsException(UserAlreadyExistsException e) {
ErrorResponse error = new ErrorResponse("USER_EXISTS", e.getMessage());
return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
}
@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity<ErrorResponse> handleUserNotFoundException(UserNotFoundException e) {
ErrorResponse error = new ErrorResponse("USER_NOT_FOUND", e.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationException(MethodArgumentNotValidException e) {
List<String> errors = e.getBindingResult()
.getFieldErrors()
.stream()
.map(error -> error.getField() + ": " + error.getDefaultMessage())
.collect(Collectors.toList());
ErrorResponse error = new ErrorResponse("VALIDATION_ERROR", "Validation failed", errors);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
}
}
@Data
class ErrorResponse {
private final String error;
private final String message;
private final Instant timestamp = Instant.now();
private List<String> details;
public ErrorResponse(String error, String message) {
this.error = error;
this.message = message;
}
public ErrorResponse(String error, String message, List<String> details) {
this.error = error;
this.message = message;
this.details = details;
}
}

Testing

1. Unit Tests
@ExtendWith(MockitoExtension.class)
class HydraServiceTest {
@Mock
private AdminApi adminApi;
@Mock
private PublicApi publicApi;
@InjectMocks
private HydraService hydraService;
@Test
void testGetLoginRequest() throws Exception {
// Given
String challenge = "test-challenge";
OAuth2LoginRequest expectedRequest = new OAuth2LoginRequest();
expectedRequest.setChallenge(challenge);
when(adminApi.getLoginRequest(challenge)).thenReturn(expectedRequest);
// When
OAuth2LoginRequest result = hydraService.getLoginRequest(challenge);
// Then
assertNotNull(result);
assertEquals(challenge, result.getChallenge());
}
}
@SpringBootTest
class UserServiceIntegrationTest {
@Autowired
private UserService userService;
@Test
void testUserCreationAndAuthentication() {
// Given
UserRegistrationRequest request = new UserRegistrationRequest();
request.setEmail("[email protected]");
request.setPassword("password123");
request.setFirstName("John");
request.setLastName("Doe");
// When
User user = userService.createUser(request);
// Then
assertNotNull(user.getId());
assertTrue(userService.validateCredentials("[email protected]", "password123"));
}
}

Best Practices

  1. Secure Configuration: Use environment variables for secrets
  2. Session Management: Implement proper session expiration
  3. Rate Limiting: Protect endpoints from abuse
  4. Audit Logging: Log all authentication and authorization events
  5. CORS Configuration: Properly configure CORS for web clients
// Example of audit logging
@Component
@Slf4j
public class AuditLogger {
public void logLoginSuccess(String userId, String clientId, String challenge) {
log.info("LOGIN_SUCCESS - user: {}, client: {}, challenge: {}", userId, clientId, challenge);
}
public void logLoginFailure(String email, String clientId, String challenge, String reason) {
log.warn("LOGIN_FAILURE - email: {}, client: {}, challenge: {}, reason: {}", 
email, clientId, challenge, reason);
}
public void logConsentGranted(String userId, String clientId, List<String> scopes) {
log.info("CONSENT_GRANTED - user: {}, client: {}, scopes: {}", userId, clientId, scopes);
}
}

Conclusion

Ory Hydra integration provides:

  • Production-ready OAuth 2.0 and OpenID Connect implementation
  • Decoupled architecture for flexible UI/UX
  • Comprehensive security with industry best practices
  • Scalable authentication flows for high-volume applications
  • Standards compliance with OpenID Certification

This implementation enables robust authentication and authorization for Java applications, supporting all standard OAuth2 flows while providing flexibility for custom login and consent experiences. The solution integrates seamlessly with Spring Security and provides comprehensive APIs for client and user management.

Leave a Reply

Your email address will not be published. Required fields are marked *


Macro Nepal Helper