Apple Login on IOS 13 with Keycloak 9.0.0 via Token Exchange
I have collect and enriched all the information (from above props to @vazul). Maby this will help someone.
We will request a JWT from Apple und use this token via token exchange as authentication.
Additionally we need a backend component do validate if this Token is issued for the correct app.
iOS App
Create a App and receive a JWT from Apple:
let appleIDProvider = ASAuthorizationAppleIDProvider()
let request = appleIDProvider.createRequest()
request.requestedScopes = [.email, .fullName]
extension LoginViewController: ASAuthorizationControllerDelegate {
func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
guard let credentials = authorization.credential as? ASAuthorizationAppleIDCredential else {
print("Could not cast credentials to ASAuthorizationAppleIDCredential.")
return
}
// Send credentials.identityToken to backend for verification
}
func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) {
guard let authError = error as? ASAuthorizationError else {
print(error.localizedDescription)
return
}
// Handle Error
// Cancelling will also result in an error
}
let authorizationController = ASAuthorizationController(authorizationRequests: [request])
// This is not required in all setups
authorizationController.presentationContextProvider = self
authorizationController.delegate = self
authorizationController.performRequests()
}
Configure identity provider
Since we dont request a JWT from apple but only validate if the JWT send by the APP is valid, we do not need most information
-
Enable the token exchange feature in Keycloak. Just add "-Dkeycloak.profile.feature.token_exchange=enabled"
and "-Dkeycloak.profile=preview"
as start up parameter.
For example overwrite the dockerimage cmd in a Dockerfile with
CMD ["-b", "0.0.0.0" ,"-Dkeycloak.profile.feature.token_exchange=enabled","-Dkeycloak.profile=preview"]
-
Create a “OpenId Connect v1.0” Identity Provide with the Following Configuration:
- Fill a alias e.G.
apple
- Hide on Login Page (We only allow the login throw the app)
- Fill the Authorization URL with a random URL e.g.
https://example.com
(we dont need it, but it does not to be blank)
- Fill the Token URL with a random URL e.g.
https://example.com
(also here a value must be present but we do not need it)
- Select a arbitrary Client Authentication from the drop down e.g.
Client secret sent as basic auth
(must be set, but me do not need it)
- Choose arbitrary client credentials e.g Client ID =
hello
and Client Secret = world
(this is not needed but does not to be empty YOU DO NOT NEED A CRON JOB!!! )
- Enable Validate Signatures
- Keep Use JWKS URL enabled
- Set
https://appleid.apple.com/auth/keys
as JWKS URL
- Set
https://appleid.apple.com
as Issuer
-
Create a Client for the App. It might be a public client lets say with the Client id ios
-
Create a Client for the backend component the does the real token exchange
- choose a Cient id e.g
apple-client
- set Access Type
confidedntial
- set a arbitrary redirect url (we do not need it during the token exchange) e.g.
https://example.com
- under Credential remember the secret
-
set allow the Client for backend component to request tokens for the app Client
- got the the apple Identity Provider
- got to the Permissions tab
- enable
Permissions Enabled
- select the appearing
token-exchange
permission
- select the Create Policy dropdown and choose client
- fill name e.g
apple-client-permission
- in the client dropdown choose apple-client
- click save
- select the new created Policy in the dropdown from the permission tab of the Identity Provider.
- go to the App Client
ios
und under Permission also enable Permissions Enabled
and add the new created Permission in the Apply Policy Dropdown
Set up a Backend component
We have a Spring Boot application within we have written the stuff.
Write a Controller:
@Data
public static class TokenRequestDTO {
private String token;
}
@PostMapping
@PreAuthorize("permitAll()")
public Map<String, Object> exchange(@RequestBody TokenRequestDTO requestDTO) {
return keycloakExchange.exchange(requestDTO);
}
then validate the given token if the audience (aud) field in the body is a known der bundle identifier
(i use this library for decoding https://mvnrepository.com/artifact/com.auth0/java-jwt)
private static final List<String> ALLOWED_AUDIENCES = Arrays.asList("a","b","c");
...
DecodedJWT decode = JWT.decode(requestDTO.getToken());
HashSet<String> strings = new HashSet<>(ALLOWED_AUDIENCES);
strings.retainAll(decode.getAudience());
if (strings.isEmpty()) {
throw new UnauthorizedException("invalid aud");
}
after the you can request from Keycloak the token and return it to your apps
RestTemplate restClient = new RestTemplate();
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
map.add("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange");
map.add("subject_token_type", "urn:ietf:params:oauth:token-type:id_token");
map.add("subject_issuer", "apple"); //identity provider id (see 2.1)
map.add("subject_token", requestDTO.getToken());
map.add("audience", "ios"); // the (public) client for the app (see 3)
map.add("client_id", "apple-client"); // the client id for the backend component (see 4.1)
map.add("client_secret", "abd"); // the client secret (see 4.3)
HttpEntity<MultiValueMap<String, String>> entity = new HttpEntity<>(map, headers);
return restClient.exchange(keycloakConfig.getAuthServerUrl() + "/auth/realms/" + keycloakConfig.getRealm() + "/protocol/openid-connect/token",
HttpMethod.POST,
entity,
new ParameterizedTypeReference<Map<String, Object>>() {
}).getBody();
Remark
As @vazul mentioned, if you have multiple Identity Provider, keycloak will iterate through them and with bad luck one other will no filled issuer is the and you will get a Nullpointer. Just set in all other Identity Provider with not an issuer a blank String.