Building a GitHub OAuth applicaiton

There is a community-supported Elixir driver for EdgeDB. In this tutorial, we’ll look at how you can create an application with authorization through GitHub using Phoenix and EdgeDB.

This tutorial is a simplified version of the LiveBeats application from fly.io with EdgeDB instead of PostgreSQL, which focuses on implementing authorization via GitHub. The completed implementation of this example can be found on GitHub. The full version of LiveBeats version on EdgeDB can also be found on GitHub

Prerequisites

For this tutorial we will need:

Before discussing the project database schema, let’s generate a sceleton for our application. We will make sure that it will use binary IDs for the Ecto schemas because EdgeDB uses UUIDs as primary IDs, which in Elixir are represented as strings, and since it is basically a plain JSON API application , we will disable all the built-in Phoenix integrations.

  1. $
  2. >
  1. mix phx.new phoenix-github_oauth --app github_oauth --module GitHubOAuth \
  2. --no-html --no-gettext --no-dashboard --no-live --no-mailer --binary-id
  1. $
  1. cd phoenix-github_oauth/

Let’s also get rid of some default things that were created by Phoenix and won’t be used by us.

  1. $
  1. # remove the module Ecto.Repo and the directory for Ecto migrations,
  1. $
  1. # because they will not be used
  1. $
  1. rm -r lib/github_oauth/repo.ex priv/repo/

And then add the EdgeDB driver, the Ecto helper for it and the Mint HTTP client for GitHub OAuth client as project dependencies to mix.exs.

  1. defmodule GitHubOAuth.MixProject do
  2. # ...
  3. defp deps do
  4. [
  5. {:phoenix, "~> 1.6.9"},
  6. {:phoenix_ecto, "~> 4.4"},
  7. {:esbuild, "~> 0.4", runtime: Mix.env() == :dev},
  8. {:telemetry_metrics, "~> 0.6"},
  9. {:telemetry_poller, "~> 1.0"},
  10. {:jason, "~> 1.2"},
  11. {:plug_cowboy, "~> 2.5"},
  12. {:edgedb, "~> 0.3.0"},
  13. {:edgedb_ecto, git: "https://github.com/nsidnev/edgedb_ecto"},
  14. {:mint, "~> 1.0"} # we need mint to write the GitHub client
  15. ]
  16. end
  17. # ...
  18. end

Now we need to download new dependencies.

  1. $
  1. mix deps.get

Next, we will create a module in lib/github_oauth/edgedb.ex which will define a child specification for the EdgeDB driver and use the EdgeDBEcto helper, which will inspect the queries that will be stored in the priv/edgeql/ directory and generate Elixir code for them.

  1. defmodule GitHubOAuth.EdgeDB do
  2. use EdgeDBEcto,
  3. name: __MODULE__,
  4. queries: true,
  5. otp_app: :github_oauth
  6. def child_spec(_opts \\ []) do
  7. %{
  8. id: __MODULE__,
  9. start: {EdgeDB, :start_link, [[name: __MODULE__]]}
  10. }
  11. end
  12. end

Now we need to add GitHubOAuth.EdgeDB as a child for our application in lib/github_oauth/application.ex (at the same time removing the child definition for Ecto.Repo from there).

  1. defmodule GitHubOAuth.Application do
  2. # ...
  3. @impl true
  4. def start(_type, _args) do
  5. children = [
  6. # Start the EdgeDB driver
  7. GitHubOAuth.EdgeDB,
  8. # Start the Telemetry supervisor
  9. GitHubOAuthWeb.Telemetry,
  10. # Start the PubSub system
  11. {Phoenix.PubSub, name: GitHubOAuth.PubSub},
  12. # Start the Endpoint (http/https)
  13. GitHubOAuthWeb.Endpoint
  14. # Start a worker by calling: GitHubOAuth.Worker.start_link(arg)
  15. # {GitHubOAuth.Worker, arg}
  16. ]
  17. # ...
  18. end
  19. # ...
  20. end

Now we are ready to start working with EdgeDB! First, let’s initialize a new project for this application.

  1. $
  1. edgedb project init
  1. No `edgedb.toml` found in `/home/<user>/phoenix-github_oauth` or above
  2. Do you want to initialize a new project? [Y/n]
  3. > Y
  4. Specify the name of EdgeDB instance to use with this project
  5. [default: phoenix_github_oauth]:
  6. > github_oauth
  7. Checking EdgeDB versions...
  8. Specify the version of EdgeDB to use with this project [default: 1.x]:
  9. > 1.x
  10. Do you want to start instance automatically on login? [y/n]
  11. > y

Great! Now we are ready to develop the database schema for the application.

Schema design

This application will have 2 types: User and Identity. The default::User represents the system user and the default::Identity represents the way the user logs in to the application (in this example via GitHub OAuth).

This schema will be stored in a single EdgeDB module inside the dbschema/default.esdl file.

  1. module default {
  2. type User {
  3. property name -> str;
  4. required property username -> str;
  5. required property email -> cistr;
  6. property profile_tagline -> str;
  7. property avatar_url -> str;
  8. property external_homepage_url -> str;
  9. required property inserted_at -> cal::local_datetime {
  10. default := cal::to_local_datetime(datetime_current(), 'UTC');
  11. }
  12. required property updated_at -> cal::local_datetime {
  13. default := cal::to_local_datetime(datetime_current(), 'UTC');
  14. }
  15. index on (.email);
  16. index on (.username);
  17. }
  18. type Identity {
  19. required property provider -> str;
  20. required property provider_token -> str;
  21. required property provider_login -> str;
  22. required property provider_email -> str;
  23. required property provider_id -> str;
  24. required property provider_meta -> json {
  25. default := <json>"{}";
  26. }
  27. required property inserted_at -> cal::local_datetime {
  28. default := cal::to_local_datetime(datetime_current(), 'UTC');
  29. }
  30. required property updated_at -> cal::local_datetime {
  31. default := cal::to_local_datetime(datetime_current(), 'UTC');
  32. }
  33. required link user -> User {
  34. on target delete delete source;
  35. }
  36. index on (.provider);
  37. constraint exclusive on ((.user, .provider));
  38. }
  39. }

After saving the file, we can create a migration for the schema and apply the generated migration.

  1. $
  1. edgedb migration create
  1. did you create object type 'default::User'? [y,n,l,c,b,s,q,?]
  2. > y
  3. did you create object type 'default::Identity'? [y,n,l,c,b,s,q,?]
  4. > y
  5. Created ./dbschema/migrations/00001.edgeql, id:
  6. m1yehm3jhj6jqwguelek54jzp4wqvvqgrcnvncxwb7676ult7nmcta
  1. $
  1. edgedb migrate

Ecto schemas

In this tutorial we will define 2 Ecto.Schema``s: for ``default::User and default::Identity types, so that we can work with EdgeDB in a more convenient and familiar to the world of Elixir.

Here is the definition for the user in the lib/accounts/user.ex file.

  1. defmodule GitHubOAuth.Accounts.User do
  2. use Ecto.Schema
  3. use EdgeDBEcto.Mapper
  4. alias GitHubOAuth.Accounts.Identity
  5. @primary_key {:id, :binary_id, autogenerate: false}
  6. schema "default::User" do
  7. field :email, :string
  8. field :name, :string
  9. field :username, :string
  10. field :avatar_url, :string
  11. field :external_homepage_url, :string
  12. has_many :identities, Identity
  13. timestamps()
  14. end
  15. end

And here for identity in lib/accounts/identity.ex.

  1. defmodule GitHubOAuth.Accounts.Identity do
  2. use Ecto.Schema
  3. use EdgeDBEcto.Mapper
  4. alias GitHubOAuth.Accounts.User
  5. @primary_key {:id, :binary_id, autogenerate: false}
  6. schema "default::Identity" do
  7. field :provider, :string
  8. field :provider_token, :string
  9. field :provider_email, :string
  10. field :provider_login, :string
  11. field :provider_name, :string, virtual: true
  12. field :provider_id, :string
  13. field :provider_meta, :map
  14. belongs_to :user, User
  15. timestamps()
  16. end
  17. end

User authentication via GitHub

This part will be pretty big, as we’ll talk about using Ecto.Changeset with the EdgeDB driver, as well as modules and queries related to user registration via GitHub OAuth.

Ecto provides Ecto.Changeset``s, which are convenient to use when working with ``Ecto.Schema to validate external parameters and we can use them also using EdgeDBEcto, though not quite as fully as we can with the full-featured adapters for Ecto.

First, we will update the GitHubOAuth.Accounts.Identity module so that it checks all the necessary parameters when we are creating a user via a GitHub registration.

  1. defmodule GitHubOAuth.Accounts.Identity do
  2. # ...
  3. import Ecto.Changeset
  4. alias GitHubOAuth.Accounts.{Identity, User}
  5. @github "github"
  6. # ...
  7. def github_registration_changeset(info, primary_email, emails, token) do
  8. params = %{
  9. "provider_token" => token,
  10. "provider_id" => to_string(info["id"]),
  11. "provider_login" => info["login"],
  12. "provider_name" => info["name"] || info["login"],
  13. "provider_email" => primary_email
  14. }
  15. %Identity{}
  16. |> cast(params, [
  17. :provider_token,
  18. :provider_email,
  19. :provider_login,
  20. :provider_name,
  21. :provider_id
  22. ])
  23. |> put_change(:provider, @github)
  24. |> put_change(:provider_meta, %{"user" => info, "emails" => emails})
  25. |> validate_required([
  26. :provider_token,
  27. :provider_email,
  28. :provider_name,
  29. :provider_id
  30. ])
  31. end
  32. end

And now let’s define a changeset for user registration, which will use an already defined changeset from GitHubOAuth.Accounts.Identity.

  1. defmodule GitHubOAuth.Accounts.User do
  2. # ...
  3. import Ecto.Changeset
  4. alias GitHubOAuth.Accounts.{User, Identity}
  5. # ...
  6. def github_registration_changeset(info, primary_email, emails, token) do
  7. %{
  8. "login" => username,
  9. "avatar_url" => avatar_url,
  10. "html_url" => external_homepage_url
  11. } = info
  12. identity_changeset =
  13. Identity.github_registration_changeset(
  14. info,
  15. primary_email,
  16. emails,
  17. token
  18. )
  19. if identity_changeset.valid? do
  20. params = %{
  21. "username" => username,
  22. "email" => primary_email,
  23. "name" => get_change(identity_changeset, :provider_name),
  24. "avatar_url" => avatar_url,
  25. "external_homepage_url" => external_homepage_url
  26. }
  27. %User{}
  28. |> cast(params, [
  29. :email,
  30. :name,
  31. :username,
  32. :avatar_url,
  33. :external_homepage_url
  34. ])
  35. |> validate_required([:email, :name, :username])
  36. |> validate_username()
  37. |> validate_email()
  38. |> put_assoc(:identities, [identity_changeset])
  39. else
  40. %User{}
  41. |> change()
  42. |> Map.put(:valid?, false)
  43. |> put_assoc(:identities, [identity_changeset])
  44. end
  45. end
  46. defp validate_email(changeset) do
  47. changeset
  48. |> validate_required([:email])
  49. |> validate_format(
  50. :email,
  51. ~r/^[^\s]+@[^\s]+$/,
  52. message: "must have the @ sign and no spaces"
  53. )
  54. |> validate_length(:email, max: 160)
  55. end
  56. defp validate_username(changeset) do
  57. validate_format(changeset, :username, ~r/^[a-zA-Z0-9_-]{2,32}$/)
  58. end
  59. end

Now that we have the schemas and changesets defined, let’s define a set of the EdgeQL queries we need for the login process.

There are 5 queries that we will need:

  1. Search for a user by user ID.

  2. Search the user by email and by identity provider.

  3. Update the identity token if the user from the 1st query exists.

  4. Registering a user along with his identity data, if the 1st request did not return the user.

  5. Querying a user identity before updating its token.

Before writing the queries themselves, let’s create a context module lib/github_oauth/accounts.ex that will use these queries, and the module itself will already be used by Phoenix controllers.

  1. defmodule GitHubOAuth.Accounts do
  2. import Ecto.Changeset
  3. alias GitHubOAuth.Accounts.{User, Identity}
  4. def get_user(id) do
  5. GitHubOAuth.EdgeDB.Accounts.get_user_by_id(id: id)
  6. end
  7. def register_github_user(primary_email, info, emails, token) do
  8. if user = get_user_by_provider(:github, primary_email) do
  9. update_github_token(user, token)
  10. else
  11. info
  12. |> User.github_registration_changeset(primary_email, emails, token)
  13. |> EdgeDBEcto.insert(
  14. &GitHubOAuth.EdgeDB.Accounts.register_github_user/1,
  15. nested: true
  16. )
  17. end
  18. end
  19. def get_user_by_provider(provider, email) when provider in [:github] do
  20. GitHubOAuth.EdgeDB.Accounts.get_user_by_provider(
  21. provider: to_string(provider),
  22. email: String.downcase(email)
  23. )
  24. end
  25. defp update_github_token(%User{} = user, new_token) do
  26. identity =
  27. GitHubOAuth.EdgeDB.Accounts.get_identity_for_user(
  28. user_id: user.id,
  29. provider: "github"
  30. )
  31. {:ok, _} =
  32. identity
  33. |> change()
  34. |> put_change(:provider_token, new_token)
  35. |> EdgeDBEcto.update(
  36. &GitHubOAuth.EdgeDB.Accounts.update_identity_token/1
  37. )
  38. identity = %Identity{identity | provider_token: new_token}
  39. {:ok, %User{user | identities: [identity]}}
  40. end
  41. end

Note that updating a token with a single query is quite easy, but we will use two separate queries, to show how to work with Ecto.Changeset in different ways.

Now that all the preparations are complete, we can start writing EdgeQL queries.

We start with the priv/edgeql/accounts/get_user_by_provider.edgeql file, which defines a query to find an user with a specified email provider.

  1. # edgedb = :query_single!
  2. # mapper = GitHubOAuth.Accounts.User
  3. select User {
  4. id,
  5. name,
  6. username,
  7. email,
  8. avatar_url,
  9. external_homepage_url,
  10. inserted_at,
  11. updated_at,
  12. }
  13. filter
  14. .<user[is Identity].provider = <str>$provider
  15. and
  16. str_lower(.email) = str_lower(<str>$email)
  17. limit 1

It is worth noting to the # edgedb = :query_single! and # mapper = GitHubOAuth.Accounts.User comments. Both are special comments that will be used by EdgeDBEcto when generating query functions. The edgedb comment defines the driver function for requesting data. Information on all supported features can be found in the driver documentation. The mapper comment is used to define the module that will be used to map the result from EdgeDB to some other form. Our Ecto.Shema``s supports this with ``use EdgeDBEcto.Mapper expression at the top of the module definition.

The queries for getting the identity and the user by ID are quite similar to the above, so we will omit them here, you can found these queries in the example repository.

Instead, let’s look at how to update the user identity. This will be described in the priv/edgeql/accounts/update_identity_token.edgeql file.

  1. # edgedb = :query_required_single
  2. with params := <json>$params
  3. update Identity
  4. filter .id = <uuid>params["id"]
  5. set {
  6. provider_token := (
  7. <str>json_get(params, "provider_token") ?? .provider_token
  8. ),
  9. updated_at := cal::to_local_datetime(datetime_current(), 'UTC'),
  10. }

As you can see, this query uses the named parameter $params instead of two separate parameters such as $id and $provider_token. This is because to update our identity we use the changeset in the module GitHubOAuth.Accounts, which automatically monitors changes to the schema and will not give back the parameters, which will not affect the state of the schema in update. So EdgeDBEcto automatically converts data from changesets when it is an update or insert operation into a named $params parameter of type JSON. It also helps to work with nested changesets, as we will see in the next query, which is defined in the priv/edgeql/accounts/register_github_user.edgeql file.

  1. # edgedb = :query_single!
  2. # mapper = GitHubOAuth.Accounts.User
  3. with
  4. params := <json>$params,
  5. identities_params := params["identities"],
  6. user := (
  7. insert User {
  8. email := <cistr>params["email"],
  9. name := <str>params["name"],
  10. username := <str>params["username"],
  11. avatar_url := <optional str>json_get(params, "avatar_url"),
  12. external_homepage_url := (
  13. <str>json_get(params, "external_homepage_url")
  14. ),
  15. }
  16. ),
  17. identites := (
  18. for identity_params in json_array_unpack(identities_params) union (
  19. insert Identity {
  20. provider := <str>identity_params["provider"],
  21. provider_token := <str>identity_params["provider_token"],
  22. provider_email := <str>identity_params["provider_email"],
  23. provider_login := <str>identity_params["provider_login"],
  24. provider_id := <str>identity_params["provider_id"],
  25. provider_meta := <json>identity_params["provider_meta"],
  26. user := user,
  27. }
  28. )
  29. )
  30. select user {
  31. id,
  32. name,
  33. username,
  34. email,
  35. avatar_url,
  36. external_homepage_url,
  37. inserted_at,
  38. updated_at,
  39. identities := identites,
  40. }

Awesome! We’re almost done with our application!

As a final step in this tutorial, we will add 2 routes for the web application. 1st for redirecting the user to the GitHub OAuth page if it’s not already logged in and show their username otherwise. And the 2nd one is for logging into the application through GitHub.

Save the GitHub OAuth credentials from the prerequisites step as GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET environment variables.

And then modify your config/dev.exs configuration file to use them.

  1. # ...
  2. config :github_oauth, :github,
  3. client_id: System.fetch_env!("GITHUB_CLIENT_ID"),
  4. client_secret: System.fetch_env!("GITHUB_CLIENT_SECRET")
  5. # ...

First we create a file lib/github_oauth_web/controllers/user_controller.ex with a controller which will show the name of the logged in user or redirect to the authentication page otherwise.

  1. defmodule GitHubOAuthWeb.UserController do
  2. use GitHubOAuthWeb, :controller
  3. alias GitHubOAuth.Accounts
  4. plug :fetch_current_user
  5. def index(conn, _params) do
  6. if conn.assigns.current_user do
  7. json(conn, %{name: conn.assigns.current_user.name})
  8. else
  9. redirect(conn, external: GitHubOAuth.GitHub.authorize_url())
  10. end
  11. end
  12. defp fetch_current_user(conn, _opts) do
  13. user_id = get_session(conn, :user_id)
  14. user = user_id && Accounts.get_user(user_id)
  15. assign(conn, :current_user, user)
  16. end
  17. end

Note that the implementation of the GitHubOAuth.GitHub module is not given here because it is relatively big and not a necessary part of this guide. If you want to explore its internals, you can check out its implementation on GitHub.

Now add an authentication controller in lib/github_oauth_web/controllers/oauth_callback_controller.ex.

  1. defmodule GitHubOAuthWeb.OAuthCallbackController do
  2. use GitHubOAuthWeb, :controller
  3. alias GitHubOAuth.Accounts
  4. require Logger
  5. def new(
  6. conn,
  7. %{"provider" => "github", "code" => code, "state" => state}
  8. ) do
  9. client = github_client(conn)
  10. with {:ok, info} <-
  11. client.exchange_access_token(code: code, state: state),
  12. %{
  13. info: info,
  14. primary_email: primary,
  15. emails: emails,
  16. token: token
  17. } = info,
  18. {:ok, user} <-
  19. Accounts.register_github_user(primary, info, emails, token) do
  20. conn
  21. |> log_in_user(user)
  22. |> redirect(to: "/")
  23. else
  24. {:error, %Ecto.Changeset{} = changeset} ->
  25. Logger.debug("failed GitHub insert #{inspect(changeset.errors)}")
  26. error =
  27. "We were unable to fetch the necessary information from " <>
  28. "your GitHub account"
  29. json(conn, %{error: error})
  30. {:error, reason} ->
  31. Logger.debug("failed GitHub exchange #{inspect(reason)}")
  32. json(conn, %{
  33. error: "We were unable to contact GitHub. Please try again later"
  34. })
  35. end
  36. end
  37. def new(conn, %{"provider" => "github", "error" => "access_denied"}) do
  38. json(conn, %{error: "Access denied"})
  39. end
  40. defp github_client(conn) do
  41. conn.assigns[:github_client] || GitHubOAuth.GitHub
  42. end
  43. defp log_in_user(conn, user) do
  44. conn
  45. |> assign(:current_user, user)
  46. |> configure_session(renew: true)
  47. |> clear_session()
  48. |> put_session(:user_id, user.id)
  49. end
  50. end

Finally, we need to change lib/github_oauth_web/router.ex and add new controllers there.

  1. defmodule GitHubOAuthWeb.Router do
  2. # ...
  3. pipeline :api do
  4. # ...
  5. plug :fetch_session
  6. end
  7. scope "/", GitHubOAuthWeb do
  8. pipe_through :api
  9. get "/", UserController, :index
  10. get "/oauth/callbacks/:provider", OAuthCallbackController, :new
  11. end
  12. # ...
  13. end

Running web server

That’s it! Now we are ready to run our application and check if everything works as expected.

  1. $
  1. mix phx.server
  1. Generated github_oauth app
  2. [info] Running GitHubOAuthWeb.Endpoint with cowboy 2.9.0 at 127.0.0.1:4000
  3. (http)
  4. [info] Access GitHubOAuthWeb.Endpoint at http://localhost:4000

After going to http://localhost:4000, we will be greeted by the GitHub authentication page. And after confirming the login we will be automatically redirected back to our local server, which will save the received user in the session and return obtained user name in the JSON response.

We can also verify that everything is saved correctly by manually checking the database data.

  1. edgedb>
  2. .......
  3. .......
  4. .......
  5. .......
  6. .......
  1. select User {
  2. name,
  3. username,
  4. avatar_url,
  5. external_homepage_url,
  6. };
  1. {
  2. default::User {
  3. name: 'Nik',
  4. username: 'nsidnev',
  5. avatar_url: 'https://avatars.githubusercontent.com/u/22559461?v=4',
  6. external_homepage_url: 'https://github.com/nsidnev'
  7. },
  8. }
  1. edgedb>
  2. .......
  3. .......
  4. .......
  5. .......
  1. select Identity {
  2. provider,
  3. provider_login
  4. }
  5. filter .user.username = 'nsidnev';
  1. {default::Identity {provider: 'github', provider_login: 'nsidnev'}}