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?
- Automated Meeting Management: Programmatically create, update, and delete meetings
- User Provisioning: Sync users between your app and Zoom
- Webinar Integration: Manage webinars and registrations
- Recording Access: Automatically process meeting recordings
- Single Sign-On: Provide seamless video conferencing within your application
Prerequisites for Zoom OAuth
- Zoom Developer Account: Create at Zoom Developer Portal
- OAuth App: Create an OAuth app in the Zoom App Marketplace
- Client Credentials: Note your Client ID and Client Secret
- 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
- Secure Token Storage: Store tokens encrypted in database
- Token Refresh: Implement automatic token refresh before expiry
- Error Handling: Handle Zoom API rate limits and errors gracefully
- Webhook Security: Validate webhook signatures
- 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.
X
Apple Sign-In provides a secure, privacy-focused authentication method for users with Apple IDs. This guide covers complete integration with Java applications using OAuth 2.0 and JWT validation.
Architecture Overview
Java Application → Apple OAuth Server → Apple ID ↑ (JWT Validation / User Info Exchange)
Step 1: Apple Developer Configuration
Prerequisites
- Apple Developer Account ($99/year)
- App ID configured with Sign In with Apple capability
- Service ID for web applications
- Private Key for JWT signing
Service Configuration
// src/main/java/com/company/apple/AppleDeveloperConfig.java
package com.company.apple;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Component
@ConfigurationProperties(prefix = "apple")
@Data
public class AppleDeveloperConfig {
// From Apple Developer Portal
private String teamId;
private String clientId;
private String keyId;
private String redirectUri;
private String scope = "name email";
// Key file details
private String privateKeyPath;
private String privateKeyContent;
// Apple URLs
private String authUrl = "https://appleid.apple.com/auth/authorize";
private String tokenUrl = "https://appleid.apple.com/auth/token";
private String revokeUrl = "https://appleid.apple.com/auth/revoke";
private String jwksUrl = "https://appleid.apple.com/auth/keys";
public String getClientSecret() {
return AppleJWTUtils.generateClientSecret(this);
}
}
Step 2: Dependencies Setup
Maven Dependencies
<!-- pom.xml --> <dependencies> <!-- Spring Security OAuth2 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-client</artifactId> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-oauth2-jose</artifactId> </dependency> <!-- JWT Processing --> <dependency> <groupId>com.nimbusds</groupId> <artifactId>nimbus-jose-jwt</artifactId> <version>9.31</version> </dependency> <!-- JSON Processing --> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> </dependency> <!-- HTTP Client --> <dependency> <groupId>org.apache.httpcomponents.client5</groupId> <artifactId>httpclient5</artifactId> </dependency> <!-- JAXB for Java 11+ --> <dependency> <groupId>javax.xml.bind</groupId> <artifactId>jaxb-api</artifactId> <version>2.3.1</version> </dependency> </dependencies>
Step 3: JWT Client Secret Generation
Client Secret Generator
// src/main/java/com/company/apple/AppleJWTUtils.java
package com.company.apple;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nimbusds.jose.JOSEException;
import com.nimbusds.jose.JOSEObjectType;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jose.JWSSigner;
import com.nimbusds.jose.crypto.ECDSASigner;
import com.nimbusds.jose.jwk.ECKey;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.interfaces.ECPrivateKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.text.ParseException;
import java.time.Instant;
import java.util.Base64;
import java.util.Date;
@Slf4j
@Component
public class AppleJWTUtils {
private static final ObjectMapper objectMapper = new ObjectMapper();
/**
* Generate client secret for Apple Sign-In
*/
public static String generateClientSecret(AppleDeveloperConfig config) {
try {
// Load private key
ECPrivateKey privateKey = loadPrivateKey(config);
// Create JWT claims
JWTClaimsSet claimsSet = new JWTClaimsSet.Builder()
.issuer(config.getTeamId())
.issueTime(new Date())
.expirationTime(Date.from(Instant.now().plusSeconds(15777000))) // 6 months
.audience("https://appleid.apple.com")
.subject(config.getClientId())
.build();
// Create JWS header
JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.ES256)
.keyID(config.getKeyId())
.type(JOSEObjectType.JWT)
.build();
// Sign JWT
SignedJWT signedJWT = new SignedJWT(header, claimsSet);
JWSSigner signer = new ECDSASigner(privateKey);
signedJWT.sign(signer);
return signedJWT.serialize();
} catch (Exception e) {
log.error("Failed to generate Apple client secret", e);
throw new RuntimeException("Apple client secret generation failed", e);
}
}
/**
* Load EC private key from file or content
*/
private static ECPrivateKey loadPrivateKey(AppleDeveloperConfig config)
throws IOException, NoSuchAlgorithmException, InvalidKeySpecException, ParseException {
String privateKeyContent = config.getPrivateKeyContent();
if (privateKeyContent == null && config.getPrivateKeyPath() != null) {
// Load from file
if (config.getPrivateKeyPath().startsWith("classpath:")) {
String path = config.getPrivateKeyPath().substring("classpath:".length());
privateKeyContent = new String(new ClassPathResource(path).getInputStream().readAllBytes());
} else {
privateKeyContent = new String(Files.readAllBytes(Paths.get(config.getPrivateKeyPath())));
}
}
if (privateKeyContent == null) {
throw new IllegalArgumentException("Apple private key not configured");
}
// Parse PEM format
privateKeyContent = privateKeyContent
.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replaceAll("\\s", "");
byte[] pkcs8EncodedKey = Base64.getDecoder().decode(privateKeyContent);
KeyFactory keyFactory = KeyFactory.getInstance("EC");
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(pkcs8EncodedKey);
return (ECPrivateKey) keyFactory.generatePrivate(keySpec);
}
/**
* Parse and validate Apple identity token
*/
public static AppleIdentityToken parseIdentityToken(String identityToken) {
try {
SignedJWT signedJWT = SignedJWT.parse(identityToken);
return AppleIdentityToken.builder()
.jwt(signedJWT)
.subject(signedJWT.getJWTClaimsSet().getSubject())
.email(signedJWT.getJWTClaimsSet().getStringClaim("email"))
.emailVerified(signedJWT.getJWTClaimsSet().getBooleanClaim("email_verified"))
.isPrivateEmail(signedJWT.getJWTClaimsSet().getBooleanClaim("is_private_email"))
.realUserStatus(signedJWT.getJWTClaimsSet().getIntegerClaim("real_user_status"))
.build();
} catch (ParseException e) {
log.error("Failed to parse Apple identity token", e);
throw new RuntimeException("Invalid Apple identity token", e);
}
}
@Data
@Builder
public static class AppleIdentityToken {
private SignedJWT jwt;
private String subject;
private String email;
private Boolean emailVerified;
private Boolean isPrivateEmail;
private Integer realUserStatus;
public boolean isEmailVerified() {
return Boolean.TRUE.equals(emailVerified);
}
public boolean isPrivateEmail() {
return Boolean.TRUE.equals(isPrivateEmail);
}
}
}
Step 4: Apple OAuth Service
Core Apple Service
// src/main/java/com/company/apple/AppleAuthService.java
package com.company.apple;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.hc.client5.http.classic.methods.HttpPost;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.core5.http.io.entity.EntityUtils;
import org.apache.hc.core5.http.io.entity.StringEntity;
import org.springframework.stereotype.Service;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
@Service
@Slf4j
@RequiredArgsConstructor
public class AppleAuthService {
private final AppleDeveloperConfig appleConfig;
private final ObjectMapper objectMapper;
/**
* Generate Apple authorization URL
*/
public String generateAuthorizationUrl(String state, String nonce) {
Map<String, String> params = new HashMap<>();
params.put("client_id", appleConfig.getClientId());
params.put("redirect_uri", appleConfig.getRedirectUri());
params.put("response_type", "code");
params.put("scope", appleConfig.getScope());
params.put("state", state);
params.put("response_mode", "form_post");
if (nonce != null) {
params.put("nonce", nonce);
}
String queryString = buildQueryString(params);
return appleConfig.getAuthUrl() + "?" + queryString;
}
/**
* Exchange authorization code for access token
*/
public AppleTokenResponse exchangeCodeForToken(String authorizationCode) {
Map<String, String> params = new HashMap<>();
params.put("client_id", appleConfig.getClientId());
params.put("client_secret", appleConfig.getClientSecret());
params.put("code", authorizationCode);
params.put("grant_type", "authorization_code");
params.put("redirect_uri", appleConfig.getRedirectUri());
return makeTokenRequest(params);
}
/**
* Refresh access token
*/
public AppleTokenResponse refreshToken(String refreshToken) {
Map<String, String> params = new HashMap<>();
params.put("client_id", appleConfig.getClientId());
params.put("client_secret", appleConfig.getClientSecret());
params.put("refresh_token", refreshToken);
params.put("grant_type", "refresh_token");
return makeTokenRequest(params);
}
/**
* Revoke token (logout)
*/
public boolean revokeToken(String token, String tokenTypeHint) {
try (CloseableHttpClient client = HttpClients.createDefault()) {
HttpPost post = new HttpPost(appleConfig.getRevokeUrl());
Map<String, String> params = new HashMap<>();
params.put("client_id", appleConfig.getClientId());
params.put("client_secret", appleConfig.getClientSecret());
params.put("token", token);
if (tokenTypeHint != null) {
params.put("token_type_hint", tokenTypeHint);
}
String formData = buildQueryString(params);
post.setEntity(new StringEntity(formData));
post.setHeader("Content-Type", "application/x-www-form-urlencoded");
try (CloseableHttpResponse response = client.execute(post)) {
int statusCode = response.getCode();
return statusCode == 200;
}
} catch (Exception e) {
log.error("Failed to revoke Apple token", e);
return false;
}
}
/**
* Validate identity token and extract user information
*/
public AppleUser validateIdentityToken(String identityToken) {
try {
AppleJWTUtils.AppleIdentityToken appleToken =
AppleJWTUtils.parseIdentityToken(identityToken);
// Additional validation can be added here
// - Verify signature using Apple's JWKS
// - Check expiration
// - Validate audience and issuer
return AppleUser.builder()
.userId(appleToken.getSubject())
.email(appleToken.getEmail())
.emailVerified(appleToken.isEmailVerified())
.isPrivateEmail(appleToken.isPrivateEmail())
.realUserStatus(appleToken.getRealUserStatus())
.build();
} catch (Exception e) {
log.error("Apple identity token validation failed", e);
throw new RuntimeException("Invalid Apple identity token", e);
}
}
private AppleTokenResponse makeTokenRequest(Map<String, String> params) {
try (CloseableHttpClient client = HttpClients.createDefault()) {
HttpPost post = new HttpPost(appleConfig.getTokenUrl());
String formData = buildQueryString(params);
post.setEntity(new StringEntity(formData));
post.setHeader("Content-Type", "application/x-www-form-urlencoded");
try (CloseableHttpResponse response = client.execute(post)) {
String responseBody = EntityUtils.toString(response.getEntity());
if (response.getCode() == 200) {
return objectMapper.readValue(responseBody, AppleTokenResponse.class);
} else {
log.error("Apple token request failed: {}", responseBody);
throw new RuntimeException("Apple token exchange failed: " + responseBody);
}
}
} catch (Exception e) {
log.error("Apple token request failed", e);
throw new RuntimeException("Apple token exchange failed", e);
}
}
private String buildQueryString(Map<String, String> params) {
StringBuilder sb = new StringBuilder();
for (Map.Entry<String, String> entry : params.entrySet()) {
if (sb.length() > 0) {
sb.append("&");
}
sb.append(URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8));
sb.append("=");
sb.append(URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8));
}
return sb.toString();
}
@Data
@Builder
public static class AppleTokenResponse {
private String access_token;
private String token_type;
private Long expires_in;
private String refresh_token;
private String id_token;
private String error;
}
@Data
@Builder
public static class AppleUser {
private String userId;
private String email;
private boolean emailVerified;
private boolean isPrivateEmail;
private Integer realUserStatus;
private String firstName;
private String lastName;
public String getUsername() {
return email != null ? email : userId;
}
}
}
Step 5: Spring Security Configuration
Security Configuration
// src/main/java/com/company/config/AppleSecurityConfig.java
package com.company.config;
import com.company.apple.AppleAuthService;
import com.company.service.AppleOAuth2UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class AppleSecurityConfig {
private final AppleOAuth2UserService appleOAuth2UserService;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(csrf -> csrf.disable())
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED))
.authorizeHttpRequests(authz -> authz
.requestMatchers("/", "/public/**", "/auth/**", "/apple/**", "/login**", "/error").permitAll()
.anyRequest().authenticated()
)
.oauth2Login(oauth2 -> oauth2
.loginPage("/auth/login")
.defaultSuccessUrl("/dashboard")
.failureUrl("/auth/login?error=true")
.userInfoEndpoint(userInfo ->
userInfo.oidcUserService(appleOAuth2UserService))
)
.logout(logout -> logout
.logoutUrl("/auth/logout")
.logoutSuccessUrl("/")
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID")
);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOriginPatterns(Arrays.asList("https://localhost:*", "https://*.company.com"));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
Custom OAuth2 User Service
// src/main/java/com/company/service/AppleOAuth2UserService.java
package com.company.service;
import com.company.apple.AppleAuthService;
import com.company.entity.AppleUserEntity;
import com.company.repository.AppleUserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.Collections;
import java.util.Map;
@Service
@Slf4j
@RequiredArgsConstructor
public class AppleOAuth2UserService extends OidcUserService {
private final AppleAuthService appleAuthService;
private final AppleUserRepository appleUserRepository;
@Override
public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException {
OidcUser oidcUser = super.loadUser(userRequest);
try {
return processOidcUser(oidcUser, userRequest);
} catch (Exception e) {
log.error("Failed to process Apple OIDC user", e);
throw new OAuth2AuthenticationException("User processing failed");
}
}
private OidcUser processOidcUser(OidcUser oidcUser, OidcUserRequest userRequest) {
Map<String, Object> attributes = oidcUser.getAttributes();
String userId = (String) attributes.get("sub");
String email = (String) attributes.get("email");
// Validate identity token with Apple
AppleAuthService.AppleUser appleUser = appleAuthService.validateIdentityToken(
userRequest.getAccessToken().getTokenValue()
);
// Save or update user in database
AppleUserEntity userEntity = saveOrUpdateUser(appleUser, attributes);
Collection<GrantedAuthority> authorities = Collections.singletonList(
new SimpleGrantedAuthority("ROLE_USER")
);
return new AppleOidcUser(oidcUser, authorities, userEntity);
}
private AppleUserEntity saveOrUpdateUser(AppleAuthService.AppleUser appleUser, Map<String, Object> attributes) {
return appleUserRepository.findByAppleUserId(appleUser.getUserId())
.map(existingUser -> {
// Update existing user
existingUser.setEmail(appleUser.getEmail());
existingUser.setEmailVerified(appleUser.isEmailVerified());
existingUser.setLastLoginAt(LocalDateTime.now());
existingUser.setAttributes(attributes);
return appleUserRepository.save(existingUser);
})
.orElseGet(() -> {
// Create new user
AppleUserEntity newUser = new AppleUserEntity();
newUser.setAppleUserId(appleUser.getUserId());
newUser.setEmail(appleUser.getEmail());
newUser.setEmailVerified(appleUser.isEmailVerified());
newUser.setFirstName((String) attributes.get("firstName"));
newUser.setLastName((String) attributes.get("lastName"));
newUser.setAttributes(attributes);
newUser.setCreatedAt(LocalDateTime.now());
newUser.setLastLoginAt(LocalDateTime.now());
return appleUserRepository.save(newUser);
});
}
/**
* Custom OIDC User implementation for Apple
*/
public static class AppleOidcUser implements OidcUser {
private final OidcUser delegate;
private final Collection<GrantedAuthority> authorities;
private final AppleUserEntity userEntity;
public AppleOidcUser(OidcUser delegate, Collection<GrantedAuthority> authorities, AppleUserEntity userEntity) {
this.delegate = delegate;
this.authorities = authorities;
this.userEntity = userEntity;
}
@Override
public Map<String, Object> getClaims() {
return delegate.getClaims();
}
@Override
public OidcUserInfo getUserInfo() {
return delegate.getUserInfo();
}
@Override
public OidcIdToken getIdToken() {
return delegate.getIdToken();
}
@Override
public String getName() {
return userEntity.getEmail();
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public Map<String, Object> getAttributes() {
return delegate.getAttributes();
}
public AppleUserEntity getUserEntity() {
return userEntity;
}
}
}
Step 6: Data Models
JPA Entities
// src/main/java/com/company/entity/AppleUserEntity.java
package com.company.entity;
import jakarta.persistence.*;
import lombok.Data;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.LocalDateTime;
import java.util.Map;
@Entity
@Table(name = "apple_users")
@Data
public class AppleUserEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "apple_user_id", unique = true, nullable = false)
private String appleUserId;
@Column(unique = true)
private String email;
@Column(name = "email_verified")
private Boolean emailVerified;
@Column(name = "first_name")
private String firstName;
@Column(name = "last_name")
private String lastName;
@Column(name = "is_private_email")
private Boolean isPrivateEmail;
@Column(name = "real_user_status")
private Integer realUserStatus;
@JdbcTypeCode(SqlTypes.JSON)
@Column(columnDefinition = "jsonb")
private Map<String, Object> attributes;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@Column(name = "last_login_at")
private LocalDateTime lastLoginAt;
@Column(name = "refresh_token")
private String refreshToken;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
}
}
// src/main/java/com/company/repository/AppleUserRepository.java
package com.company.repository;
import com.company.entity.AppleUserEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface AppleUserRepository extends JpaRepository<AppleUserEntity, Long> {
Optional<AppleUserEntity> findByAppleUserId(String appleUserId);
Optional<AppleUserEntity> findByEmail(String email);
boolean existsByAppleUserId(String appleUserId);
}
Step 7: REST API Controllers
Authentication Controller
// src/main/java/com/company/controller/AppleAuthController.java
package com.company.controller;
import com.company.apple.AppleAuthService;
import com.company.apple.AppleDeveloperConfig;
import com.company.service.AppleOAuth2UserService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api/apple")
@RequiredArgsConstructor
@Slf4j
public class AppleAuthController {
private final AppleAuthService appleAuthService;
private final AppleDeveloperConfig appleConfig;
private final AppleOAuth2UserService appleOAuth2UserService;
@GetMapping("/auth-url")
public ResponseEntity<Map<String, String>> getAppleAuthUrl(HttpSession session) {
String state = UUID.randomUUID().toString();
String nonce = UUID.randomUUID().toString();
// Store state and nonce in session for validation
session.setAttribute("apple_oauth_state", state);
session.setAttribute("apple_oauth_nonce", nonce);
String authUrl = appleAuthService.generateAuthorizationUrl(state, nonce);
return ResponseEntity.ok(Map.of("url", authUrl));
}
@PostMapping("/callback")
public ResponseEntity<Map<String, Object>> handleAppleCallback(
@RequestParam("code") String authorizationCode,
@RequestParam("state") String state,
@RequestParam(value = "id_token", required = false) String identityToken,
@RequestParam(value = "user", required = false) String userJson,
HttpServletRequest request) {
try {
// Validate state parameter
HttpSession session = request.getSession(false);
String expectedState = session != null ?
(String) session.getAttribute("apple_oauth_state") : null;
if (expectedState == null || !expectedState.equals(state)) {
return ResponseEntity.badRequest().body(Map.of(
"error", "invalid_state",
"message", "State parameter validation failed"
));
}
// Exchange code for tokens
AppleAuthService.AppleTokenResponse tokenResponse =
appleAuthService.exchangeCodeForToken(authorizationCode);
// Validate identity token and get user info
AppleAuthService.AppleUser appleUser =
appleAuthService.validateIdentityToken(tokenResponse.getId_token());
// Clean up session
if (session != null) {
session.removeAttribute("apple_oauth_state");
session.removeAttribute("apple_oauth_nonce");
}
Map<String, Object> response = new HashMap<>();
response.put("status", "success");
response.put("user", appleUser);
response.put("access_token", tokenResponse.getAccess_token());
response.put("refresh_token", tokenResponse.getRefresh_token());
return ResponseEntity.ok(response);
} catch (Exception e) {
log.error("Apple callback processing failed", e);
return ResponseEntity.badRequest().body(Map.of(
"error", "processing_failed",
"message", e.getMessage()
));
}
}
@PostMapping("/refresh-token")
public ResponseEntity<Map<String, Object>> refreshToken(
@RequestBody RefreshTokenRequest request) {
try {
AppleAuthService.AppleTokenResponse tokenResponse =
appleAuthService.refreshToken(request.getRefreshToken());
return ResponseEntity.ok(Map.of(
"access_token", tokenResponse.getAccess_token(),
"refresh_token", tokenResponse.getRefresh_token(),
"expires_in", tokenResponse.getExpires_in()
));
} catch (Exception e) {
log.error("Token refresh failed", e);
return ResponseEntity.badRequest().body(Map.of(
"error", "refresh_failed",
"message", e.getMessage()
));
}
}
@PostMapping("/revoke")
public ResponseEntity<Map<String, Object>> revokeToken(
@RequestBody RevokeTokenRequest request) {
try {
boolean success = appleAuthService.revokeToken(
request.getToken(),
request.getTokenTypeHint()
);
if (success) {
return ResponseEntity.ok(Map.of("status", "success"));
} else {
return ResponseEntity.badRequest().body(Map.of(
"error", "revoke_failed",
"message", "Token revocation failed"
));
}
} catch (Exception e) {
log.error("Token revocation failed", e);
return ResponseEntity.badRequest().body(Map.of(
"error", "revoke_failed",
"message", e.getMessage()
));
}
}
@GetMapping("/user/profile")
public ResponseEntity<Map<String, Object>> getUserProfile(
@AuthenticationPrincipal AppleOAuth2UserService.AppleOidcUser appleUser) {
if (appleUser == null) {
return ResponseEntity.badRequest().body(Map.of(
"error", "not_authenticated",
"message", "User not authenticated with Apple"
));
}
Map<String, Object> profile = new HashMap<>();
profile.put("userId", appleUser.getUserEntity().getAppleUserId());
profile.put("email", appleUser.getUserEntity().getEmail());
profile.put("firstName", appleUser.getUserEntity().getFirstName());
profile.put("lastName", appleUser.getUserEntity().getLastName());
profile.put("emailVerified", appleUser.getUserEntity().getEmailVerified());
profile.put("isPrivateEmail", appleUser.getUserEntity().getIsPrivateEmail());
return ResponseEntity.ok(profile);
}
// Request DTOs
@Data
public static class RefreshTokenRequest {
private String refreshToken;
}
@Data
public static class RevokeTokenRequest {
private String token;
private String tokenTypeHint; // "access_token" or "refresh_token"
}
}
Step 8: Frontend Integration
HTML/JavaScript Example
<!-- src/main/resources/static/apple-signin.html -->
<!DOCTYPE html>
<html>
<head>
<title>Apple Sign-In Demo</title>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<style>
.apple-signin-button {
background-color: #000;
color: #fff;
border: none;
border-radius: 6px;
padding: 12px 24px;
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
font-size: 16px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.apple-signin-button:hover {
background-color: #333;
}
.apple-icon {
width: 18px;
height: 18px;
}
</style>
</head>
<body>
<div style="max-width: 400px; margin: 100px auto; text-align: center;">
<h2>Apple Sign-In Demo</h2>
<button class="apple-signin-button" onclick="signInWithApple()">
<svg class="apple-icon" viewBox="0 0 24 24">
<path fill="currentColor" d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z"/>
</svg>
Sign in with Apple
</button>
<div id="result" style="margin-top: 20px; padding: 15px; border-radius: 6px; display: none;"></div>
</div>
<script>
const baseUrl = 'http://localhost:8080/api/apple';
async function signInWithApple() {
try {
// Get Apple authorization URL from backend
const response = await axios.get(`${baseUrl}/auth-url`);
const authUrl = response.data.url;
// Open Apple sign-in in popup
const popup = window.open(authUrl, 'appleSignIn',
'width=500,height=600,scrollbars=yes');
// Listen for message from popup
window.addEventListener('message', function(event) {
if (event.origin !== window.location.origin) return;
if (event.data.type === 'apple_signin_success') {
handleAppleSuccess(event.data);
popup.close();
} else if (event.data.type === 'apple_signin_error') {
showError(event.data.error);
popup.close();
}
});
} catch (error) {
console.error('Apple Sign-In failed:', error);
showError('Failed to start Apple Sign-In');
}
}
function handleAppleSuccess(data) {
const resultDiv = document.getElementById('result');
resultDiv.style.display = 'block';
resultDiv.style.backgroundColor = '#d4edda';
resultDiv.style.color = '#155724';
resultDiv.innerHTML = `
<h3>Sign-In Successful!</h3>
<p><strong>User ID:</strong> ${data.user.userId}</p>
<p><strong>Email:</strong> ${data.user.email}</p>
<p><strong>Email Verified:</strong> ${data.user.emailVerified}</p>
`;
}
function showError(message) {
const resultDiv = document.getElementById('result');
resultDiv.style.display = 'block';
resultDiv.style.backgroundColor = '#f8d7da';
resultDiv.style.color = '#721c24';
resultDiv.innerHTML = `<strong>Error:</strong> ${message}`;
}
// For form post response handling (Apple's response_mode)
if (window.location.search.includes('code=')) {
// Parse URL parameters
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const state = urlParams.get('state');
const idToken = urlParams.get('id_token');
const user = urlParams.get('user');
// Send to parent window
window.opener.postMessage({
type: 'apple_signin_success',
code: code,
state: state,
id_token: idToken,
user: user ? JSON.parse(user) : null
}, window.location.origin);
window.close();
}
</script>
</body>
</html>
Step 9: Application Configuration
application.yml
# application.yml
spring:
datasource:
url: jdbc:h2:mem:appledb
driverClassName: org.h2.Driver
username: sa
password:
jpa:
database-platform: org.hibernate.dialect.H2Dialect
hibernate:
ddl-auto: create-drop
show-sql: true
h2:
console:
enabled: true
security:
oauth2:
client:
registration:
apple:
client-id: ${APPLE_CLIENT_ID}
client-secret: ${APPLE_CLIENT_SECRET}
scope: name,email
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/apple"
provider:
apple:
authorization-uri: https://appleid.apple.com/auth/authorize
token-uri: https://appleid.apple.com/auth/token
jwk-set-uri: https://appleid.apple.com/auth/keys
user-info-uri: https://appleid.apple.com/auth/userinfo
apple:
team-id: ${APPLE_TEAM_ID}
client-id: ${APPLE_CLIENT_ID}
key-id: ${APPLE_KEY_ID}
redirect-uri: ${APPLE_REDIRECT_URI:https://localhost:8080/api/apple/callback}
private-key-path: ${APPLE_PRIVATE_KEY_PATH:classpath:apple/AuthKey.p8}
server:
port: 8080
logging:
level:
com.company.apple: DEBUG
Best Practices
- Security
- Always use HTTPS in production
- Validate state parameter to prevent CSRF
- Store private keys securely (not in version control)
- Implement proper token expiration handling
- User Experience
- Provide fallback authentication options
- Handle email privacy features (private relay)
- Support both popup and redirect flows
- Implement proper error handling
- Compliance
- Follow Apple's design guidelines
- Implement proper data privacy controls
- Provide account deletion options
- Performance
- Cache Apple's JWKS
- Implement token refresh strategies
- Use connection pooling for HTTP clients
Conclusion
Apple Sign-In implementation provides:
- Enhanced Security: Built on OAuth 2.0 and PKCE
- User Privacy: Email hiding and privacy features
- Seamless UX: Native integration with Apple devices
- Trust: Apple's reputation for security and privacy
Implementation steps:
- Configure Apple Developer account and Service ID
- Generate and secure private key
- Implement JWT client secret generation
- Create OAuth 2.0 flow handlers
- Integrate with Spring Security
- Add frontend components
- Implement token management and refresh
This implementation provides a production-ready Apple Sign-In solution that can be extended with additional features like Sign in with Apple JS framework or native iOS integration.