Last active
December 30, 2022 13:19
-
-
Save RZetko/985c5cac9511f6115cf5103f2d49c3e4 to your computer and use it in GitHub Desktop.
Apple Sign In Java Micronaut implementation
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Works with Micronaut 3.5.3. | |
Don't forget to: | |
- change package names according to your project | |
- replace path to your AuthKey.p8 file in AppleSignInUtil.java and set up your resources accordingly | |
- implement your own AuthExpection or use generic Exception |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.example.yourpackagename.model; | |
import com.fasterxml.jackson.annotation.JsonProperty; | |
public class AppleSignInCallbackDetails { | |
String code; | |
@JsonProperty("id_token") | |
String idToken; | |
public String getCode() { | |
return code; | |
} | |
public void setCode(String code) { | |
this.code = code; | |
} | |
public String getIdToken() { | |
return idToken; | |
} | |
public void setIdToken(String idToken) { | |
this.idToken = idToken; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.example.yourpackagename.model; | |
public class AppleSignInDetails { | |
String authorizationCode; | |
String email; | |
String familyName; | |
String givenName; | |
String identityToken; | |
String state; | |
String userIdentifier; | |
Boolean isWeb = false; | |
public String getEmail() { | |
return email; | |
} | |
public void setEmail(String email) { | |
this.email = email; | |
} | |
public String getFamilyName() { | |
return familyName; | |
} | |
public void setFamilyName(String familyName) { | |
this.familyName = familyName; | |
} | |
public String getGivenName() { | |
return givenName; | |
} | |
public void setGivenName(String givenName) { | |
this.givenName = givenName; | |
} | |
public String getState() { | |
return state; | |
} | |
public void setState(String state) { | |
this.state = state; | |
} | |
public String getUserIdentifier() { | |
return userIdentifier; | |
} | |
public void setUserIdentifier(String userIdentifier) { | |
this.userIdentifier = userIdentifier; | |
} | |
public String getAuthorizationCode() { | |
return authorizationCode; | |
} | |
public void setAuthorizationCode(String code) { | |
this.authorizationCode = code; | |
} | |
public String getIdentityToken() { | |
return identityToken; | |
} | |
public void setIdentityToken(String idToken) { | |
this.identityToken = idToken; | |
} | |
public Boolean getIsWeb() { | |
return isWeb; | |
} | |
public void getIsWeb(Boolean isWeb) { | |
this.isWeb = isWeb; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.example.yourpackagename.model; | |
import com.fasterxml.jackson.annotation.JsonProperty; | |
public class AppleSignInIdTokenPayload { | |
private String iss; | |
private String sub; | |
private String aud; | |
private Long iat; | |
private Long exp; | |
private String nonce; | |
@JsonProperty("nonce_supported") | |
private Boolean nonceSupported; | |
private String email; | |
@JsonProperty("email_verified") | |
private Boolean emailVerified; | |
@JsonProperty("real_user_status") | |
private Integer realUserStatus; | |
@JsonProperty("is_private_email") | |
private String isPrivateEmail; | |
@JsonProperty("transfer_sub") | |
private String transferSub; | |
@JsonProperty("at_hash") | |
private String atHash; | |
@JsonProperty("auth_time") | |
private Long authTime; | |
public String getIss() { | |
return iss; | |
} | |
public void setIss(String iss) { | |
this.iss = iss; | |
} | |
public String getSub() { | |
return sub; | |
} | |
public void setSub(String sub) { | |
this.sub = sub; | |
} | |
public String getAud() { | |
return aud; | |
} | |
public void setAud(String aud) { | |
this.aud = aud; | |
} | |
public Long getIat() { | |
return iat; | |
} | |
public void setIat(Long iat) { | |
this.iat = iat; | |
} | |
public Long getExp() { | |
return exp; | |
} | |
public void setExp(Long exp) { | |
this.exp = exp; | |
} | |
public String getNonce() { | |
return nonce; | |
} | |
public void setNonce(String nonce) { | |
this.nonce = nonce; | |
} | |
public Boolean getNonceSupported() { | |
return nonceSupported; | |
} | |
public void setNonceSupported(Boolean nonceSupported) { | |
this.nonceSupported = nonceSupported; | |
} | |
public String getEmail() { | |
return email; | |
} | |
public void setEmail(String email) { | |
this.email = email; | |
} | |
public Boolean getEmailVerified() { | |
return emailVerified; | |
} | |
public void setEmailVerified(Boolean emailVerified) { | |
this.emailVerified = emailVerified; | |
} | |
public Integer getRealUserStatus() { | |
return realUserStatus; | |
} | |
public void setRealUserStatus(Integer realUserStatus) { | |
this.realUserStatus = realUserStatus; | |
} | |
public String getIsPrivateEmail() { | |
return isPrivateEmail; | |
} | |
public void setIsPrivateEmail(String isPrivateEmail) { | |
this.isPrivateEmail = isPrivateEmail; | |
} | |
public String getTransferSub() { | |
return transferSub; | |
} | |
public void setTransferSub(String transferSub) { | |
this.transferSub = transferSub; | |
} | |
public String getAtHash() { | |
return atHash; | |
} | |
public void setAtHash(String atHash) { | |
this.atHash = atHash; | |
} | |
public Long getAuthTime() { | |
return authTime; | |
} | |
public void setAuthTime(Long authTime) { | |
this.authTime = authTime; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.example.yourpackagename.model; | |
import com.fasterxml.jackson.annotation.JsonProperty; | |
public class AppleSignInTokenResponse { | |
@JsonProperty("access_token") | |
private String accessToken; | |
@JsonProperty("token_type") | |
private String tokenType; | |
@JsonProperty("expires_in") | |
private Long expiresIn; | |
@JsonProperty("refresh_token") | |
private String refreshToken; | |
@JsonProperty("id_token") | |
private String idToken; | |
public String getAccessToken() { | |
return accessToken; | |
} | |
public void setAccessToken(String accessToken) { | |
this.accessToken = accessToken; | |
} | |
public String getTokenType() { | |
return tokenType; | |
} | |
public void setTokenType(String tokenType) { | |
this.tokenType = tokenType; | |
} | |
public Long getExpiresIn() { | |
return expiresIn; | |
} | |
public void setExpiresIn(Long expiresIn) { | |
this.expiresIn = expiresIn; | |
} | |
public String getRefreshToken() { | |
return refreshToken; | |
} | |
public void setRefreshToken(String refreshToken) { | |
this.refreshToken = refreshToken; | |
} | |
public String getIdToken() { | |
return idToken; | |
} | |
public void setIdToken(String idToken) { | |
this.idToken = idToken; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.example.yourpackagename.utils; | |
import java.io.IOException; | |
import java.io.InputStream; | |
import java.io.InputStreamReader; | |
import java.net.URI; | |
import java.net.URISyntaxException; | |
import java.net.URLEncoder; | |
import java.net.http.HttpRequest; | |
import java.net.http.HttpResponse; | |
import java.net.http.HttpClient; | |
import java.net.http.HttpClient.Version; | |
import java.nio.charset.StandardCharsets; | |
import java.security.PrivateKey; | |
import java.time.Duration; | |
import java.util.Date; | |
import java.util.HashMap; | |
import java.util.Map; | |
import java.util.Optional; | |
import java.util.stream.Collectors; | |
import org.bouncycastle.asn1.pkcs.PrivateKeyInfo; | |
import org.bouncycastle.openssl.PEMParser; | |
import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter; | |
import com.example.yourpackagename.model.AppleSignInIdTokenPayload; | |
import com.example.yourpackagename.model.AppleSignInTokenResponse; | |
import com.example.yourpackagename.user.AuthException; | |
import com.fasterxml.jackson.databind.ObjectMapper; | |
import io.jsonwebtoken.JwsHeader; | |
import io.jsonwebtoken.Jwts; | |
import io.jsonwebtoken.SignatureAlgorithm; | |
import io.jsonwebtoken.io.Decoders; | |
import io.micronaut.core.io.ResourceResolver; | |
import io.micronaut.core.io.scan.ClassPathResourceLoader; | |
public class AppleSignInUtil { | |
private String baseUrl; | |
private String keyId; | |
private String teamId; | |
private String clientId; | |
private String webClientId; | |
private String webRedirectUrl; | |
private PrivateKey pKey; | |
private HttpClient httpClient; | |
public AppleSignInUtil( | |
String baseUrl, | |
String keyId, | |
String teamId, | |
String clientId, | |
String webClientId, | |
String webRedirectUrl) { | |
this.baseUrl = baseUrl; | |
this.keyId = keyId; | |
this.teamId = teamId; | |
this.clientId = clientId; | |
this.webClientId = webClientId; | |
this.webRedirectUrl = webRedirectUrl; | |
this.httpClient = HttpClient.newBuilder() | |
.version(Version.HTTP_1_1) | |
.connectTimeout(Duration.ofSeconds(15)) | |
.build(); | |
} | |
private static PrivateKey getPrivateKey() throws IOException { | |
Optional<ClassPathResourceLoader> optionalLoader = new ResourceResolver() | |
.getLoader(ClassPathResourceLoader.class); | |
if (optionalLoader.isPresent()) { | |
ClassPathResourceLoader loader = optionalLoader.get(); | |
Optional<InputStream> optionalInputStream = loader | |
.getResourceAsStream("classpath:apple/AuthKey_XXXXXXXXXX.p8"); | |
if (optionalInputStream.isPresent()) { | |
InputStream appleAuthKeyInputStream = optionalInputStream.get(); | |
final PEMParser pemParser = new PEMParser(new InputStreamReader(appleAuthKeyInputStream)); | |
final JcaPEMKeyConverter converter = new JcaPEMKeyConverter(); | |
final PrivateKeyInfo object = (PrivateKeyInfo) pemParser.readObject(); | |
return converter.getPrivateKey(object); | |
} | |
} | |
return null; | |
} | |
private String generateJWT() throws IOException { | |
if (pKey == null) { | |
pKey = getPrivateKey(); | |
} | |
return Jwts.builder() | |
.setHeaderParam(JwsHeader.KEY_ID, keyId) | |
.setIssuer(teamId) | |
.setAudience(baseUrl) | |
.setSubject(clientId) | |
.setExpiration(new Date(System.currentTimeMillis() + (1000 * 60 * 5))) | |
.setIssuedAt(new Date(System.currentTimeMillis())) | |
.signWith(pKey, SignatureAlgorithm.ES256) | |
.compact(); | |
} | |
private String generateWebJWT() throws IOException { | |
return Jwts.builder() | |
.setHeaderParam(JwsHeader.KEY_ID, keyId) | |
.setIssuer(teamId) | |
.setAudience(baseUrl) | |
.setSubject(webClientId) | |
.setExpiration(new Date(System.currentTimeMillis() + (1000 * 60 * 5))) | |
.setIssuedAt(new Date(System.currentTimeMillis())) | |
.signWith(getPrivateKey(), SignatureAlgorithm.ES256) | |
.compact(); | |
} | |
public AppleSignInIdTokenPayload appleAuth(String authorizationCode, boolean isWeb) | |
throws IOException, URISyntaxException, InterruptedException, AuthException { | |
Map<String, String> requestBody = new HashMap<>(); | |
requestBody.put("client_id", isWeb ? webClientId : clientId); | |
requestBody.put("client_secret", isWeb ? generateWebJWT() : generateJWT()); | |
requestBody.put("grant_type", "authorization_code"); | |
requestBody.put("code", authorizationCode); | |
if (isWeb) { | |
requestBody.put("redirect_uri", webRedirectUrl); | |
} | |
String formBody = requestBody.entrySet() | |
.stream() | |
.map(e -> e.getKey() + "=" + URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8)) | |
.collect(Collectors.joining("&")); | |
HttpRequest req = HttpRequest.newBuilder() | |
.uri(new URI(baseUrl + "/auth/token")) | |
.header("Content-Type", "application/x-www-form-urlencoded") | |
.POST(HttpRequest.BodyPublishers.ofString(formBody)) | |
.build(); | |
HttpResponse<String> response = httpClient.send(req, HttpResponse.BodyHandlers.ofString()); | |
if (response.statusCode() != 200) { | |
throw new AuthException(response.body()); | |
} | |
ObjectMapper mapper = new ObjectMapper(); | |
AppleSignInTokenResponse tokenResponse = mapper.readValue(response.body(), AppleSignInTokenResponse.class); | |
String idToken = tokenResponse.getIdToken(); | |
String payload = idToken.split("\\.")[1];// 0 is header we ignore it for now | |
String decoded = new String(Decoders.BASE64.decode(payload)); | |
return mapper.readValue(decoded, AppleSignInIdTokenPayload.class); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment