|
| 1 | +package com.accedia.apple.auth; |
| 2 | + |
| 3 | +import com.accedia.apple.auth.user.AppleAuthorizationToken; |
| 4 | +import com.accedia.apple.auth.user.UserData; |
| 5 | +import com.accedia.apple.auth.user.UserDataDeserializer; |
| 6 | +import com.auth0.jwt.JWT; |
| 7 | +import com.auth0.jwt.JWTVerifier; |
| 8 | +import com.auth0.jwt.algorithms.Algorithm; |
| 9 | +import com.auth0.jwt.interfaces.RSAKeyProvider; |
| 10 | +import com.google.api.client.auth.oauth2.*; |
| 11 | +import com.google.api.client.http.GenericUrl; |
| 12 | +import com.google.api.client.http.HttpTransport; |
| 13 | +import com.google.api.client.http.javanet.NetHttpTransport; |
| 14 | +import com.google.api.client.json.JsonFactory; |
| 15 | +import com.google.api.client.json.jackson2.JacksonFactory; |
| 16 | +import com.google.common.base.Supplier; |
| 17 | +import com.google.common.base.Suppliers; |
| 18 | +import com.google.common.collect.ImmutableList; |
| 19 | + |
| 20 | +import javax.annotation.Nullable; |
| 21 | +import java.io.IOException; |
| 22 | +import java.security.interfaces.ECPrivateKey; |
| 23 | +import java.time.Instant; |
| 24 | +import java.util.Arrays; |
| 25 | +import java.util.Collection; |
| 26 | +import java.util.List; |
| 27 | +import java.util.Optional; |
| 28 | +import java.util.concurrent.TimeUnit; |
| 29 | +import java.util.stream.Collectors; |
| 30 | + |
| 31 | +public class AppleAuthProvider { |
| 32 | + |
| 33 | + private final static String DEFAULT_APPLE_AUTH_TOKEN_URL = "https://appleid.apple.com/auth/token"; |
| 34 | + private final static String DEFAULT_APPLE_AUTH_AUTHORIZE_URL = "https://appleid.apple.com/auth/authorize"; |
| 35 | + private final static long DEFAULT_SECRET_LIFE_IN_SEC = 20 * 60; |
| 36 | + private final static long DEFAULT_MAX_TIMEOUT_IN_SEC = 20; |
| 37 | + |
| 38 | + private final String clientId; |
| 39 | + private final Supplier<ClientParametersAuthentication> appleClientParameters; |
| 40 | + private final SecretGenerator secretGenerator; |
| 41 | + private final ECPrivateKey ecPrivateKey; |
| 42 | + private final Supplier<Instant> nowSupplier; |
| 43 | + private final String keyId; |
| 44 | + private final String teamId; |
| 45 | + private final long secretLifeInSec; |
| 46 | + private final GenericUrl appleAuthTokenUrl; |
| 47 | + private final String appleAuthAuthorizationUrl; |
| 48 | + private UserDataDeserializer userDataDeserializer; |
| 49 | + private final List<String> appleUserScopes; |
| 50 | + private final String redirectUrl; |
| 51 | + private final HttpTransport httpTransport; |
| 52 | + private final JsonFactory jsonFactory; |
| 53 | + private final JWTVerifier jwtVerifier; |
| 54 | + |
| 55 | + |
| 56 | + /** |
| 57 | + * A constructor with minimum configuration. Uses default Apple URLs, http transport, json factory and timings. |
| 58 | + * |
| 59 | + * @param clientId A10-character key identifier obtained from your developer account. |
| 60 | + * (aka "Service ID" that is configured for “Sign In with Apple") |
| 61 | + * @param keyId A 10-character key identifier obtained from your developer account. |
| 62 | + * Configured for "Sign In with Apple". |
| 63 | + * @param teamId A 10-character key identifier obtained from your developer account. |
| 64 | + * @param secretGenerator A provider for the client secret as specified in apple auth id. |
| 65 | + * @param privateKey The private key as supplied by Apple. |
| 66 | + * @param redirectUrl URL to which the user will be redirected after successful verification. |
| 67 | + * You need to configure a verified domain and map the redirect URL to it. |
| 68 | + * Can’t be an IP address or localhost. Can be left null if url generation won't |
| 69 | + * be used. |
| 70 | + * @param scopes The apple required scopes. Values given here will determine the content of the |
| 71 | + * User Data returned in the tokens. Can be left null if url generation won't |
| 72 | + * be used. |
| 73 | + */ |
| 74 | + public AppleAuthProvider(String clientId, String keyId, String teamId, SecretGenerator secretGenerator, |
| 75 | + ECPrivateKey privateKey, @Nullable Collection<AppleUserScope> scopes, |
| 76 | + @Nullable String redirectUrl) { |
| 77 | + this(clientId, |
| 78 | + keyId, |
| 79 | + teamId, |
| 80 | + DEFAULT_APPLE_AUTH_TOKEN_URL, |
| 81 | + DEFAULT_APPLE_AUTH_AUTHORIZE_URL, |
| 82 | + new UserDataDeserializer(), |
| 83 | + new NetHttpTransport(), |
| 84 | + new JacksonFactory(), |
| 85 | + DEFAULT_MAX_TIMEOUT_IN_SEC, |
| 86 | + secretGenerator, |
| 87 | + privateKey, |
| 88 | + () -> Instant.now(), |
| 89 | + DEFAULT_SECRET_LIFE_IN_SEC, |
| 90 | + new AppleKeyProvider(), |
| 91 | + scopes, |
| 92 | + redirectUrl |
| 93 | + ); |
| 94 | + } |
| 95 | + |
| 96 | + /** |
| 97 | + * Constructor using no default values. |
| 98 | + * |
| 99 | + * @param clientId A10-character key identifier obtained from your developer account. |
| 100 | + * (aka "Service ID" that is configured for “Sign In with Apple") |
| 101 | + * @param keyId A 10-character key identifier obtained from your developer account. |
| 102 | + * Configured for "Sign In with Apple". |
| 103 | + * @param teamId A 10-character key identifier obtained from your developer account. |
| 104 | + * @param redirectUrl URL to which the user will be redirected after successful verification. |
| 105 | + * You need to configure a verified domain and map the redirect URL to it. |
| 106 | + * Can’t be an IP address or localhost. |
| 107 | + * @param scopes The apple required scopes. Values given here will determine the content of the |
| 108 | + * User Data returned |
| 109 | + * in the tokens. |
| 110 | + * @param ecPrivateKey The private key supplied by apple. |
| 111 | + * @param secretGenerator A provider for the client secret as specified in apple auth id. |
| 112 | + * @param secretLifeInSec The lifespan of the client secret in seconds. |
| 113 | + * @param appleAuthTokenUrl The apple oauth2 url for issuing Authentication and Verification tokens. |
| 114 | + * @param appleAuthAuthorizationUrl The apple url for beginning the Auth login. |
| 115 | + * @param userDataDeserializer A deserializer used to extract user data from the raw apple token response. |
| 116 | + * @param httpTransport The transport that will be used to make the token authenticate nad validate |
| 117 | + * requests. |
| 118 | + * @param jsonFactory The factory used to create the parser and generator for the tokens. |
| 119 | + * @param appleKeyProvider A provider that will be used to validate the token responses. |
| 120 | + * @param maxTimeoutInSec The Maximum allowed time for a http request before a timeout occurs. |
| 121 | + * @param nowSupplier Should return the current instance. |
| 122 | + */ |
| 123 | + public AppleAuthProvider(String clientId, |
| 124 | + String keyId, |
| 125 | + String teamId, |
| 126 | + String appleAuthTokenUrl, |
| 127 | + String appleAuthAuthorizationUrl, |
| 128 | + UserDataDeserializer userDataDeserializer, |
| 129 | + HttpTransport httpTransport, |
| 130 | + JsonFactory jsonFactory, |
| 131 | + long maxTimeoutInSec, |
| 132 | + SecretGenerator secretGenerator, |
| 133 | + ECPrivateKey ecPrivateKey, |
| 134 | + Supplier<Instant> nowSupplier, |
| 135 | + long secretLifeInSec, |
| 136 | + RSAKeyProvider appleKeyProvider, |
| 137 | + @Nullable Collection<AppleUserScope> scopes, |
| 138 | + @Nullable String redirectUrl |
| 139 | + ) { |
| 140 | + this.clientId = clientId; |
| 141 | + this.secretGenerator = secretGenerator; |
| 142 | + this.ecPrivateKey = ecPrivateKey; |
| 143 | + this.nowSupplier = nowSupplier; |
| 144 | + this.keyId = keyId; |
| 145 | + this.teamId = teamId; |
| 146 | + this.secretLifeInSec = secretLifeInSec; |
| 147 | + this.userDataDeserializer = userDataDeserializer; |
| 148 | + this.appleUserScopes = scopes != null ? ImmutableList.copyOf( |
| 149 | + scopes.stream().map(AppleUserScope::getLiteral).collect(Collectors.toList()) |
| 150 | + ) : null; |
| 151 | + this.appleAuthTokenUrl = new GenericUrl(appleAuthTokenUrl); |
| 152 | + this.jsonFactory = jsonFactory; |
| 153 | + this.appleAuthAuthorizationUrl = appleAuthAuthorizationUrl; |
| 154 | + appleClientParameters = Suppliers.memoizeWithExpiration(this::generateClientAuthParameterSet, |
| 155 | + this.secretLifeInSec - maxTimeoutInSec, |
| 156 | + TimeUnit.SECONDS |
| 157 | + ); |
| 158 | + this.redirectUrl = redirectUrl; |
| 159 | + this.httpTransport = httpTransport; |
| 160 | + |
| 161 | + Algorithm validationAlg = Algorithm.RSA256(appleKeyProvider); |
| 162 | + this.jwtVerifier = JWT.require(validationAlg) |
| 163 | + .build(); |
| 164 | + |
| 165 | + } |
| 166 | + |
| 167 | + private ClientParametersAuthentication generateClientAuthParameterSet() { |
| 168 | + String newSecret = secretGenerator.generateSecret(ecPrivateKey, keyId, teamId, clientId, |
| 169 | + nowSupplier.get(), secretLifeInSec); |
| 170 | + return new ClientParametersAuthentication(clientId, newSecret); |
| 171 | + } |
| 172 | + |
| 173 | + /** |
| 174 | + * Generates a login link that will take the user Apple's authentication portal. |
| 175 | + * @param state A string that will be passed back when the user eventually makes their way to the redirect url. |
| 176 | + * This will be automatically escaped. |
| 177 | + * @return A URL ready for embedding. |
| 178 | + */ |
| 179 | + public String getLoginLink(String state) { |
| 180 | + return new AuthorizationCodeRequestUrl(appleAuthAuthorizationUrl, clientId) |
| 181 | + .setRedirectUri(this.redirectUrl) |
| 182 | + .setResponseTypes(Arrays.asList("code", "id_token")) |
| 183 | + .setScopes(appleUserScopes) |
| 184 | + .setState(state) |
| 185 | + .set("response_mode", "form_post")//Could be parameterized based on scope. |
| 186 | + .build(); |
| 187 | + } |
| 188 | + |
| 189 | + /** |
| 190 | + * Makes an authorisation request. Retrieves an User's date from Apple. Use this object to create users or sessions. |
| 191 | + * @param authCode Received from Apple after successfully redirecting the use. |
| 192 | + * @throws IOException |
| 193 | + */ |
| 194 | + public AppleAuthorizationToken makeNewAuthorisationTokenRequest(String authCode) throws IOException { |
| 195 | + AuthorizationCodeTokenRequest authoriseTokenRequest |
| 196 | + = new AuthorizationCodeTokenRequest(httpTransport, jsonFactory, appleAuthTokenUrl, authCode); |
| 197 | + |
| 198 | + return executeTokenRequest(authoriseTokenRequest); |
| 199 | + } |
| 200 | + |
| 201 | + /** |
| 202 | + * Verifies if a token is valid. |
| 203 | + * Use this method to check daily if the user is still signed in on your app using Apple ID. |
| 204 | + * @param refreshToken |
| 205 | + * @return |
| 206 | + * @throws IOException |
| 207 | + */ |
| 208 | + public AppleAuthorizationToken makeNewRefreshTokenRequest(String refreshToken) throws IOException { |
| 209 | + RefreshTokenRequest refreshTokenRequest |
| 210 | + = new RefreshTokenRequest(httpTransport, jsonFactory, appleAuthTokenUrl, refreshToken); |
| 211 | + |
| 212 | + return executeTokenRequest(refreshTokenRequest); |
| 213 | + } |
| 214 | + |
| 215 | + private AppleAuthorizationToken executeTokenRequest(TokenRequest tokenRequest) throws IOException { |
| 216 | + |
| 217 | + tokenRequest.setClientAuthentication(appleClientParameters.get()); |
| 218 | + TokenResponse tokenResponse = tokenRequest.execute(); |
| 219 | + Optional<String> idToken = Optional.ofNullable(tokenResponse.get("id_token")).map(Object::toString); |
| 220 | + idToken.ifPresent(this::validateToken); |
| 221 | + Optional<UserData> userData = idToken.map(userDataDeserializer::getUserDataFromIdToken); |
| 222 | + return new AppleAuthorizationToken( |
| 223 | + tokenResponse.getAccessToken(), |
| 224 | + tokenResponse.getExpiresInSeconds(), |
| 225 | + idToken.orElse(null), |
| 226 | + tokenResponse.getRefreshToken(), |
| 227 | + userData.orElse(null)); |
| 228 | + } |
| 229 | + |
| 230 | + private void validateToken(String token) { |
| 231 | + jwtVerifier.verify(token); |
| 232 | + } |
| 233 | + |
| 234 | +} |
0 commit comments