diff --git a/README.md b/README.md index bc1a3f0..cac1c3d 100644 --- a/README.md +++ b/README.md @@ -210,6 +210,44 @@ iex(4)> FirebaseAdminEx.Auth.generate_sign_in_with_email_link(action_code_settin "{\n \"kind\": \"identitytoolkit#GetOobConfirmationCodeResponse\",\n \"email\": \"user@email.com\",\n \"oobLink\": \"https://YOUR-FIREBASE-CLIENT.firebaseapp.com/__/auth/action?mode=signIn&oobCode=xcdwelFRvfbtghHjswvw2f3g46hh6j8&apiKey=Fgae35h6j78_vbsddgs34th6h6hhekj97gfj&lang=en&continueUrl=www.test.com/sign-in\"\n}\n"} ``` +* Verify user's ID token + +```ex +# Fetch the id_token generated by firebase client for the user and pass to to verify_token(id_token, allow_unverified \\ true). It checks for valid certificate, issuer, audience, expiry and optionally if the user has a verified email address. +#Returns user object with token details on successful verification. + +#Valid id token +iex(5)> id_token = "eyJhbGciOiJSUzI1NiIsImtpZCI6ImQ1OThkYjVjZjE1ZWNhOTI0OWJhZTUzMDYzOWVkYzUzNmMzYzViYjUiLCJ0eXAiOiJKV1QifQ" +iex(6)> FirebaseAdminEx.Auth.verify_token(id_token) +{:ok, + %{ + "aud" => "YOUR-FIREBASE-PROJECT", + "auth_time" => 1577079586, + "email" => "useremail@domain.com", + "email_verified" => false, + "exp" => 1577087136, + "firebase" => %{ + "identities" => %{"email" => ["useremail@domain.com"]}, + "sign_in_provider" => "password" + }, + "iat" => 1577083536, + "iss" => "https://securetoken.google.com/YOUR-FIREBASE-PROJECT", + "sub" => "xxxxxxxxxxxxxxxxxxxx", + "user_id" => "USER-UID-ON-FIREBASE" + }} + +#Expired id_token +iex(7)> id_token = "eyJhbGciOiJSUzI1NiIsImtpZCI6ImQ1OThkYjVjZjE1ZWNhOTI0OWJhZTUzMDYzOWVkYzUzNmMzYzViYjUiLCJ0eXAiOiJKtyerdf" +iex(8)> FirebaseAdminEx.Auth.verify_token(id_token) +{:error, "Token has passed it's expiry time"} + +#Force check for verified email address +iex(9)> id_token = "eyJhbGciOiJSUzI1NiIsImtpZCI6ImQ1OThkYjVjZjE1ZWNhOTI0OWJhZTUzMDYzOWVkYzUzNmMzYzViYjUiLCJ0eXAiOiJKV1QifQ" +iex(10)> FirebaseAdminEx.Auth.verify_token(id_token,false) +{:error, "Email is not verified"} +``` + + ## Firebase Documentation * [Setup Guide](https://firebase.google.com/docs/admin/setup/) diff --git a/lib/auth.ex b/lib/auth.ex index 79f17ee..39bb8b7 100644 --- a/lib/auth.ex +++ b/lib/auth.ex @@ -5,6 +5,7 @@ defmodule FirebaseAdminEx.Auth do @auth_endpoint "https://www.googleapis.com/identitytoolkit/v3/relyingparty/" @auth_endpoint_account "https://identitytoolkit.googleapis.com/v1/projects/" @auth_scope "https://www.googleapis.com/auth/cloud-platform" + @auth_cert_url "https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com" @doc """ Get a user's info by UID @@ -36,6 +37,20 @@ defmodule FirebaseAdminEx.Auth do def delete_user(uid, client_email \\ nil), do: do_request("deleteAccount", %{localId: uid}, client_email) + @doc """ + Update an existing user by UID + Pick one or more fields that require updation. + Passing the firebase UID as local_id is mandatory, force check this in the client before using update_user api. + iex(9)> request_body = %{email_verified: true, local_id: "iD1vovu1QsOcOFrkB2XBw2F4jsZ2"} + %{email_verified: true, local_id: "iD1vovu1QsOcOFrkB2XBw2F4jsZ2"} + iex(10)> FirebaseAdminEx.Auth.update_user(request_body) + {:ok, _UPDATED_USER_BODY_RESPONSE} + + """ + @spec update_user(map, String.t() | nil) :: tuple() + def update_user(request_body, client_email \\ nil), + do: do_request("setAccountInfo", request_body, client_email) + # TODO: Add other commands: # list_users # create_user @@ -67,6 +82,105 @@ defmodule FirebaseAdminEx.Auth do end end + @doc """ + Fetch environment specific Firebase project_id + """ + def firebase_project_id() do + Goth.Config.get(:project_id) |> elem(1) + end + + @doc """ + Verify id token based on certificate, token issuer and timestamp + """ + @spec verify_token(String.t(), boolean()) :: tuple() + def verify_token(id_token, allow_unverified \\ true) do + with {:ok, fields} <- resolve_token_firebase(id_token, @auth_cert_url), + {true,_} <- check_payload_email_verified(fields["email_verified"],allow_unverified), + {true,_} <- check_payload_audience(fields["aud"]), + {true,_} <- check_payload_issuer(fields["iss"]), + {true,_} <- check_payload_expiry(fields["exp"]) do + {:ok, fields} + else + {:error, reason} -> {:error, reason} + {false, reason} -> {:error, reason} + end + end + + @doc """ + Resolve id token based on certificate into firebase user data + """ + @spec resolve_token_firebase(String.t(), String.t()) :: tuple() + def resolve_token_firebase(id_token, cert_url) do + with true <- !is_nil(id_token), + {:ok, response} <- HTTPoison.get(cert_url), + %{body: body} = response, + certs = Poison.Parser.parse!(body, %{}), + {:ok, header} <- Joken.peek_header(id_token), + jwks = JOSE.JWK.from_firebase(certs), + true <- !is_nil(jwks[header["kid"]]), + jwk = jwks[header["kid"]] |> JOSE.JWK.to_map |> elem(1), + {true, jose_jwt, _} = JOSE.JWT.verify(jwk, id_token) do + fields = JOSE.JWT.to_map(jose_jwt) |> elem(1) + {:ok, fields} + else + false -> {:error, "id_token is nil"} + {:error, _} -> {:error, "Unable to resolve id_token error"} + {false,_} -> {:error, "Unable to resolve id_token due to certificate mismatch"} + end + end + + @doc """ + Check if the email in the payload is verified. If client allows unverified users, + set allow_unverified to true + """ + @spec check_payload_email_verified(String.t(), boolean()) :: tuple() + def check_payload_email_verified(field_value, allow_unverified) do + if field_value || allow_unverified do + {true, "Email is verified"} + else + {false, "Email is not verified"} + end + end + + @doc """ + Check if the issuer in the payload matches client's firebase project + """ + @spec check_payload_issuer(String.t()) :: tuple() + def check_payload_issuer(field_value) do + firebase_project_id = firebase_project_id() + if field_value == "https://securetoken.google.com/" <> firebase_project_id do + {true, "Issuer matches client's firebase project"} + else + {false, "Token issuer does not match client's firebase project"} + end + end + + @doc """ + Check if the audience in the payload matches client's firebase project + """ + @spec check_payload_audience(String.t()) :: tuple() + def check_payload_audience(field_value) do + firebase_project_id = firebase_project_id() + if field_value == firebase_project_id do + {true, "Audience matches client's firebase project"} + else + {false, "Token aud does not match client's firebase project"} + end + end + + @doc """ + Check that the token expiry time has not passed. + """ + @spec check_payload_expiry(integer()) :: tuple() + def check_payload_expiry(field_value) do + current_datetime = DateTime.utc_now |> DateTime.to_unix + if field_value > current_datetime do + {true, "Token is valid"} + else + {false, "Token has passed it's expiry time"} + end + end + defp do_request(url_suffix, payload, client_email, project_id) do with {:ok, response} <- Request.request( diff --git a/mix.exs b/mix.exs index 81c9424..af3676f 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule FirebaseAdminEx.MixProject do def project do [ app: :firebase_admin_ex, - version: "0.2.0", + version: "0.2.3", elixir: "~> 1.6", start_permanent: Mix.env() == :prod, deps: deps(),