How to implement Apple Login in a Phoenix app?

Hey everyone!
I’m currently implementing sign in with apple on my app. I’m already at the point where I got the JWT that I need to decode to validate the user and the key hash from apple (includes the kid, the alg etc…
How can I use the information from the key hash to decode the JWT correctly?
In Ruby it seems pretty straight forward with

keyHash = ActiveSupport::HashWithIndifferentAccess.new(apple_certificate["keys"].select {|key| key["kid"] == kid}[0])
jwk = JWT::JWK.import(keyHash)
token_data = JWT.decode(jwt, jwk.public_key, true, {algorithm: alg})[0]

But I’m having a hard time finding the equivalent in Elixir. I took a look at JOSE.JWK but I’m not sure which method I should use to generate the JWK and fetch the public key afterward
Thanks!

2 Likes

With Assent it is very easy too (in my mix file: {:assent, "~> 0.1.13"}). You just have to pass the apple authorization code (ASAuthorizationAppleIDCredential -> authorizationCode) and the apple_id is then verified. No need to manually pass the public key and algorithm. You don’t even need to pass the identityToken:

def check_apple_sign_in(_apple_jwt, apple_auth) do
    [
     client_id: "com.your_domain.your_app",
     team_id: "YOUR_ID",
     private_key_id: "KEY_ID",
     private_key_path: "/Users/you/development/Keys/AuthKey_2YZ4LV4PYI.p8",
     redirect_uri: nil
    ]
    |> Assent.Config.put(:session_params, %{})
    |> Assent.Strategy.Apple.callback(%{"code" => apple_auth})
    |> case do
      {:ok, %{user: user, token: token}} ->
        %{"sub" => user_id} = user
        Logger.info "apple sign in verification successful. user: #{Kernel.inspect(user)}; token: #{Kernel.inspect(token)}"
        {:ok, user_id} #the user_id you are interested in
      error ->
        Logger.info "apple sign in verification not successful: #{Kernel.inspect(error)}"
        {:error, :authentication}
    end
  end
4 Likes

I recently implemented Sign in with Apple in our app, and I used Joken to handle both token verification and client secret generation.

Here’s what I did to decode the JWT (Note: My example uses Joken v1.5)

token = Joken.token(id_token)
%{"alg" => alg, "kid" => key_id} = Joken.peek_header(token)

token
|> Joken.with_signer(Signer.rs(alg, public_key))
|> Joken.with_validation("aud", &(&1 == "<client_id>"))
|> Joken.with_validation("exp", &(&1 >= System.os_time(:second)))
|> Joken.with_validation("iss", &(&1 == "<issuer>"))
|> Joken.verify!()

public_key above is the matching key fetched from Apple’s public key endpoint.

Hope this helps - and feel free to reach out with additional questions

8 Likes

Were you able to solve your problem?

Hey, sorry for the late reply!
Yes, I was able to solve it but I followed a different approach, didn’t use Joken or Assent, just used JOSE directly.

    key_hash = Enum.find(parsed_body["keys"], fn key -> key["kid"] == kid end)

    jwk = JOSE.JWK.from_map(key_hash)

    case JOSE.JWT.verify_strict(jwk, [alg], token) do
        ...
    end
3 Likes

Just in case: please note that jose does not validate any claims of a jwt. It only verifies the signature - so you have to check all other things by yourself - especially exp and aud. Just don’t forget that!

Joken provides sane default behavior - which is useful. I did jwt handling both jose only and jose/joken and both are pretty straightforward.

Probably harder part is to parse a key in PEM format haha.

3 Likes

Hey @chulkilee, thanks for your feedback! Yes, I did indeed do the part of checking the uid, exp, aud, iss, etc!

I was a new person. What should I do after the validation is done?

defmodule Accounts.AppleAuth do
  @apple_keys_url "https://appleid.apple.com/auth/keys"
  @aud "com.blahblah"

  def verify_identity_token(identity_token) do
    # Fetch Apple's public keys
    case get_apple_public_keys() do
      {:ok, apple_keys} ->
        with {:ok, token} <- decode_and_verify(identity_token, apple_keys) do
          verify_claims(token)
        end

      {:error, reason} ->
        {:error, reason}
    end
  end

  defp get_apple_public_keys() do
    case HTTPoison.get(@apple_keys_url) do
      {:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
        case Jason.decode(body) do
          {:ok, %{"keys" => keys}} ->
            {:ok, keys}
          {:error, _} ->
            {:error, "Failed to decode Apple's public keys"}
        end

      {:ok, %HTTPoison.Response{status_code: status_code}} ->
        {:error, "Failed to fetch Apple's public keys, status code: #{status_code}"}

      {:error, %HTTPoison.Error{reason: reason}} ->
        {:error, "HTTP request failed: #{reason}"}
    end
  end

  defp decode_and_verify(token, apple_keys) do
    # Find the appropriate key for the token
    key = find_apple_key(token, apple_keys)

    if key do
      jwk = JOSE.JWK.from(key)
      {verified, jwt, _} = JOSE.JWT.verify_strict(jwk, ["RS256"], token)
      if verified, do: {:ok, jwt}, else: {:error, "Invalid token signature"}
    else
      {:error, "No matching key found"}
    end
  end

  defp verify_claims(jwt) do
    %JOSE.JWT{fields: claims} = jwt

    case claims do
      %{"aud" => @aud, "exp" => exp} ->
        current_time = System.system_time(:second)

        if exp > current_time do
          {:ok, claims}
        else
          {:error, "Token has expired"}
        end

      _ ->
        {:error, "Invalid claims"}
    end
  end

  defp find_apple_key(token, keys) do
    # Decode token header to find matching `kid`
    %JOSE.JWS{fields: %{"kid" => kid}} = JOSE.JWT.peek_protected(token)
    Enum.find(keys, fn key -> key["kid"] == kid end)
  end
end

The above seems to be working for me! Hope it helps someone out one day.

9 Likes

This is more than enough and you shouldn’t need anything more, same goes for android, they have their verification key at a known URL, in the exact same format (at least it used to be about 4 years ago when I implemented the same thing).