google-site-verification: google61fe8ba583a51912.html
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