Seamless Video Integration: Implementing Zoom OAuth in Java Applications


Article

In today's remote-first world, integrating video conferencing capabilities into Java applications has become essential. Zoom OAuth provides a secure, standardized way to connect your Java applications with Zoom's APIs, enabling features like automated meeting creation, user management, and webinar integration. For Java developers building applications that need video collaboration features, Zoom OAuth offers a robust foundation for creating seamless user experiences.

What is Zoom OAuth?

Zoom uses the OAuth 2.0 protocol to allow Java applications to access Zoom APIs on behalf of users or accounts. There are two main OAuth flows:

  • OAuth Authorization Code Grant: For user-level authentication (most common)
  • Server-to-Server OAuth: For account-level access without user interaction

Why Integrate Zoom OAuth in Java Applications?

  1. Automated Meeting Management: Programmatically create, update, and delete meetings
  2. User Provisioning: Sync users between your app and Zoom
  3. Webinar Integration: Manage webinars and registrations
  4. Recording Access: Automatically process meeting recordings
  5. Single Sign-On: Provide seamless video conferencing within your application

Prerequisites for Zoom OAuth

  1. Zoom Developer Account: Create at Zoom Developer Portal
  2. OAuth App: Create an OAuth app in the Zoom App Marketplace
  3. Client Credentials: Note your Client ID and Client Secret
  4. Redirect URI: Configure your application's callback URL

Zoom OAuth Implementation in Java

Approach 1: Spring Boot OAuth2 Client Integration

1. Add Dependencies:

<!-- pom.xml -->
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!-- For REST API calls -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
</dependencies>

2. Application Configuration:

# application.yml
spring:
security:
oauth2:
client:
registration:
zoom:
client-id: ${ZOOM_CLIENT_ID}
client-secret: ${ZOOM_CLIENT_SECRET}
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/zoom"
scope: 
- user:read:meetings
- meeting:write
- meeting:read
provider:
zoom:
authorization-uri: https://zoom.us/oauth/authorize
token-uri: https://zoom.us/oauth/token
user-info-uri: https://api.zoom.us/v2/users/me
user-name-attribute: email
# Zoom API Configuration
zoom:
api:
base-url: https://api.zoom.us/v2
webhook:
verification-token: ${ZOOM_WEBHOOK_VERIFICATION_TOKEN}
# Server configuration
server:
port: 8080
servlet:
context-path: /

3. Security Configuration:

@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/", "/login", "/error", "/webhook/**").permitAll()
.anyRequest().authenticated()
)
.oauth2Login(oauth2 -> oauth2
.loginPage("/login")
.defaultSuccessUrl("/dashboard", true)
.failureUrl("/login?error=true")
)
.logout(logout -> logout
.logoutSuccessUrl("/")
.invalidateHttpSession(true)
.clearAuthentication(true)
.deleteCookies("JSESSIONID")
)
.csrf(csrf -> csrf
.ignoringRequestMatchers("/webhook/**") // Zoom webhooks don't use CSRF
);
return http.build();
}
}

4. Zoom OAuth Service:

@Service
public class ZoomOAuthService {
private final String zoomApiBaseUrl = "https://api.zoom.us/v2";
private final WebClient webClient;
private final String clientId;
private final String clientSecret;
public ZoomOAuthService(@Value("${spring.security.oauth2.client.registration.zoom.client-id}") String clientId,
@Value("${spring.security.oauth2.client.registration.zoom.client-secret}") String clientSecret) {
this.clientId = clientId;
this.clientSecret = clientSecret;
this.webClient = WebClient.builder()
.baseUrl(zoomApiBaseUrl)
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.build();
}
public String getAccessToken(String authorizationCode, String redirectUri) {
try {
ZoomTokenResponse response = webClient.post()
.uri("https://zoom.us/oauth/token")
.header(HttpHeaders.AUTHORIZATION, getBasicAuthHeader())
.bodyValue(buildTokenRequest(authorizationCode, redirectUri))
.retrieve()
.bodyToMono(ZoomTokenResponse.class)
.block();
return response.getAccessToken();
} catch (Exception e) {
throw new RuntimeException("Failed to get access token", e);
}
}
public ZoomUser getUserProfile(String accessToken) {
try {
return webClient.get()
.uri("/users/me")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)
.retrieve()
.bodyToMono(ZoomUser.class)
.block();
} catch (Exception e) {
throw new RuntimeException("Failed to get user profile", e);
}
}
public ZoomMeeting createMeeting(String accessToken, CreateMeetingRequest request) {
try {
return webClient.post()
.uri("/users/me/meetings")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)
.bodyValue(request)
.retrieve()
.bodyToMono(ZoomMeeting.class)
.block();
} catch (Exception e) {
throw new RuntimeException("Failed to create meeting", e);
}
}
public List<ZoomMeeting> listMeetings(String accessToken, MeetingListRequest request) {
try {
ZoomMeetingListResponse response = webClient.get()
.uri(uriBuilder -> uriBuilder
.path("/users/me/meetings")
.queryParam("type", request.getType())
.queryParam("page_size", request.getPageSize())
.queryParam("page_number", request.getPageNumber())
.build())
.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)
.retrieve()
.bodyToMono(ZoomMeetingListResponse.class)
.block();
return response != null ? response.getMeetings() : Collections.emptyList();
} catch (Exception e) {
throw new RuntimeException("Failed to list meetings", e);
}
}
public void updateMeeting(String accessToken, String meetingId, UpdateMeetingRequest request) {
try {
webClient.patch()
.uri("/meetings/{meetingId}", meetingId)
.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)
.bodyValue(request)
.retrieve()
.toBodilessEntity()
.block();
} catch (Exception e) {
throw new RuntimeException("Failed to update meeting", e);
}
}
public void deleteMeeting(String accessToken, String meetingId) {
try {
webClient.delete()
.uri("/meetings/{meetingId}", meetingId)
.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)
.retrieve()
.toBodilessEntity()
.block();
} catch (Exception e) {
throw new RuntimeException("Failed to delete meeting", e);
}
}
private String getBasicAuthHeader() {
String credentials = clientId + ":" + clientSecret;
return "Basic " + Base64.getEncoder().encodeToString(credentials.getBytes());
}
private MultiValueMap<String, String> buildTokenRequest(String code, String redirectUri) {
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
formData.add("grant_type", "authorization_code");
formData.add("code", code);
formData.add("redirect_uri", redirectUri);
return formData;
}
}

5. Data Models:

@Data
public class ZoomTokenResponse {
private String access_token;
private String token_type;
private String refresh_token;
private Integer expires_in;
private String scope;
}
@Data
public class ZoomUser {
private String id;
private String first_name;
private String last_name;
private String email;
private String type;
private String role_name;
private String pmi;
private Boolean use_pmi;
private String personal_meeting_url;
private String timezone;
private String verified;
private String created_at;
private String last_login_time;
}
@Data
public class ZoomMeeting {
private String uuid;
private Long id;
private String host_id;
private String topic;
private Integer type;
private String status;
private String start_time;
private Integer duration;
private String timezone;
private String agenda;
private String created_at;
private String start_url;
private String join_url;
private String password;
private MeetingSettings settings;
}
@Data
public class CreateMeetingRequest {
private String topic;
private Integer type = 2; // 1 - Instant, 2 - Scheduled, 3 - Recurring, 8 - Fixed Webinar
private String start_time;
private Integer duration;
private String timezone = "America/New_York";
private String agenda;
private MeetingSettings settings;
@Data
public static class MeetingSettings {
private Boolean host_video = true;
private Boolean participant_video = true;
private Boolean join_before_host = false;
private Boolean mute_upon_entry = false;
private Boolean waiting_room = true;
private String audio = "both"; // both, telephony, voip
private String auto_recording = "none"; // none, local, cloud
}
}

6. Controller Implementation:

@Controller
public class ZoomController {
private final ZoomOAuthService zoomService;
public ZoomController(ZoomOAuthService zoomService) {
this.zoomService = zoomService;
}
@GetMapping("/dashboard")
public String dashboard(Model model, @AuthenticationPrincipal OAuth2User principal) {
if (principal != null) {
String accessToken = extractAccessToken(principal);
ZoomUser user = zoomService.getUserProfile(accessToken);
List<ZoomMeeting> meetings = zoomService.listMeetings(accessToken, 
new MeetingListRequest("upcoming", 10, 1));
model.addAttribute("user", user);
model.addAttribute("meetings", meetings);
}
return "dashboard";
}
@PostMapping("/meetings/create")
public String createMeeting(@ModelAttribute CreateMeetingRequest request,
@AuthenticationPrincipal OAuth2User principal,
RedirectAttributes redirectAttributes) {
try {
String accessToken = extractAccessToken(principal);
ZoomMeeting meeting = zoomService.createMeeting(accessToken, request);
redirectAttributes.addFlashAttribute("success", 
"Meeting created successfully: " + meeting.getJoin_url());
} catch (Exception e) {
redirectAttributes.addFlashAttribute("error", 
"Failed to create meeting: " + e.getMessage());
}
return "redirect:/dashboard";
}
@PostMapping("/meetings/{meetingId}/delete")
public String deleteMeeting(@PathVariable String meetingId,
@AuthenticationPrincipal OAuth2User principal,
RedirectAttributes redirectAttributes) {
try {
String accessToken = extractAccessToken(principal);
zoomService.deleteMeeting(accessToken, meetingId);
redirectAttributes.addFlashAttribute("success", "Meeting deleted successfully");
} catch (Exception e) {
redirectAttributes.addFlashAttribute("error", 
"Failed to delete meeting: " + e.getMessage());
}
return "redirect:/dashboard";
}
private String extractAccessToken(OAuth2User principal) {
// In a real application, store the access token securely
// This is a simplified example
return "stored-access-token"; // You would retrieve this from your token store
}
}

Approach 2: Manual OAuth Flow Implementation

For more control over the OAuth flow, implement it manually:

@Service
public class ManualZoomOAuthService {
private final String clientId;
private final String clientSecret;
private final String redirectUri;
public ManualZoomOAuthService(@Value("${zoom.client.id}") String clientId,
@Value("${zoom.client.secret}") String clientSecret,
@Value("${zoom.redirect.uri}") String redirectUri) {
this.clientId = clientId;
this.clientSecret = clientSecret;
this.redirectUri = redirectUri;
}
public String generateAuthorizationUrl(String state) {
return String.format(
"https://zoom.us/oauth/authorize?response_type=code&client_id=%s&redirect_uri=%s&state=%s",
clientId, URLEncoder.encode(redirectUri, StandardCharsets.UTF_8), state);
}
public ZoomTokenResponse exchangeCodeForToken(String authorizationCode) {
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://zoom.us/oauth/token"))
.header("Content-Type", "application/x-www-form-urlencoded")
.header("Authorization", getBasicAuthHeader())
.POST(HttpRequest.BodyPublishers.ofString(
String.format("grant_type=authorization_code&code=%s&redirect_uri=%s",
authorizationCode, URLEncoder.encode(redirectUri, StandardCharsets.UTF_8)))
)
.build();
HttpClient client = HttpClient.newHttpClient();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
ObjectMapper mapper = new ObjectMapper();
return mapper.readValue(response.body(), ZoomTokenResponse.class);
} catch (Exception e) {
throw new RuntimeException("Failed to exchange code for token", e);
}
}
public ZoomTokenResponse refreshToken(String refreshToken) {
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://zoom.us/oauth/token"))
.header("Content-Type", "application/x-www-form-urlencoded")
.header("Authorization", getBasicAuthHeader())
.POST(HttpRequest.BodyPublishers.ofString(
String.format("grant_type=refresh_token&refresh_token=%s", refreshToken))
)
.build();
HttpClient client = HttpClient.newHttpClient();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
ObjectMapper mapper = new ObjectMapper();
return mapper.readValue(response.body(), ZoomTokenResponse.class);
} catch (Exception e) {
throw new RuntimeException("Failed to refresh token", e);
}
}
private String getBasicAuthHeader() {
String credentials = clientId + ":" + clientSecret;
return "Basic " + Base64.getEncoder().encodeToString(credentials.getBytes());
}
}

7. Webhook Handling for Zoom Events:

@RestController
@RequestMapping("/webhook/zoom")
public class ZoomWebhookController {
private final String verificationToken;
public ZoomWebhookController(@Value("${zoom.webhook.verification-token}") String verificationToken) {
this.verificationToken = verificationToken;
}
@PostMapping("/events")
public ResponseEntity<String> handleWebhook(
@RequestHeader("authorization") String authorization,
@RequestBody String payload) {
// Verify the webhook
if (!verificationToken.equals(authorization)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
try {
ObjectMapper mapper = new ObjectMapper();
ZoomWebhookEvent event = mapper.readValue(payload, ZoomWebhookEvent.class);
// Handle different event types
switch (event.getEvent()) {
case "meeting.started":
handleMeetingStarted(event);
break;
case "meeting.ended":
handleMeetingEnded(event);
break;
case "recording.completed":
handleRecordingCompleted(event);
break;
default:
logger.info("Unhandled webhook event: {}", event.getEvent());
}
return ResponseEntity.ok("Webhook processed successfully");
} catch (Exception e) {
logger.error("Failed to process webhook", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
private void handleMeetingStarted(ZoomWebhookEvent event) {
// Update meeting status in your database
logger.info("Meeting started: {}", event.getPayload().getObject().getId());
}
private void handleMeetingEnded(ZoomWebhookEvent event) {
// Process meeting end, maybe generate reports
logger.info("Meeting ended: {}", event.getPayload().getObject().getId());
}
private void handleRecordingCompleted(ZoomWebhookEvent event) {
// Process recording, maybe download or notify users
logger.info("Recording completed for meeting: {}", event.getPayload().getObject().getId());
}
}

8. Token Storage Service:

@Service
@Transactional
public class ZoomTokenStorageService {
@PersistenceContext
private EntityManager entityManager;
public void storeTokens(String userId, ZoomTokenResponse tokenResponse) {
ZoomOAuthToken token = entityManager.find(ZoomOAuthToken.class, userId);
if (token == null) {
token = new ZoomOAuthToken();
token.setUserId(userId);
}
token.setAccessToken(tokenResponse.getAccess_token());
token.setRefreshToken(tokenResponse.getRefresh_token());
token.setExpiresAt(Instant.now().plusSeconds(tokenResponse.getExpires_in()));
token.setScope(tokenResponse.getScope());
token.setUpdatedAt(Instant.now());
entityManager.merge(token);
}
public ZoomOAuthToken getTokens(String userId) {
return entityManager.find(ZoomOAuthToken.class, userId);
}
public boolean isTokenExpired(String userId) {
ZoomOAuthToken token = getTokens(userId);
return token == null || token.getExpiresAt().isBefore(Instant.now().plusMinutes(5));
}
public void refreshTokensIfNeeded(String userId) {
if (isTokenExpired(userId)) {
ZoomOAuthToken token = getTokens(userId);
if (token != null) {
ZoomTokenResponse newTokens = zoomService.refreshToken(token.getRefreshToken());
storeTokens(userId, newTokens);
}
}
}
}

Best Practices for Production

  1. Secure Token Storage: Store tokens encrypted in database
  2. Token Refresh: Implement automatic token refresh before expiry
  3. Error Handling: Handle Zoom API rate limits and errors gracefully
  4. Webhook Security: Validate webhook signatures
  5. Rate Limiting: Implement rate limiting for API calls
@Aspect
@Component
public class ZoomApiAuditAspect {
private static final Logger logger = LoggerFactory.getLogger(ZoomApiAuditAspect.class);
@AfterReturning("execution(* com.example.service.ZoomOAuthService.*(..))")
public void auditZoomApiCall(JoinPoint joinPoint) {
logger.info("Zoom API called: {}", joinPoint.getSignature().getName());
}
@AfterThrowing(pointcut = "execution(* com.example.service.ZoomOAuthService.*(..))", 
throwing = "ex")
public void auditZoomApiError(JoinPoint joinPoint, Exception ex) {
logger.error("Zoom API error in {}: {}", joinPoint.getSignature().getName(), ex.getMessage());
}
}

Conclusion

Integrating Zoom OAuth into Java applications provides powerful video conferencing capabilities that can enhance user experience and automate meeting management. By leveraging Spring Security's OAuth2 client support or implementing manual OAuth flows, Java developers can create seamless integrations that handle authentication, meeting management, and real-time webhook events.

The combination of robust Java backend services with Zoom's comprehensive API enables building enterprise-grade applications with integrated video collaboration features. As remote work continues to evolve, Zoom OAuth integration becomes an essential component of modern Java applications.

Leave a Reply

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


Macro Nepal Helper