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:
- EdgeDB CLI.
- Elixir version 1.13 or higher.
- Phoenix framework version 1.6 or higher.
- GitHub OAuth application.
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. # ...
4. defp deps do
5. [
6. {:phoenix, "~> 1.6.9"},
7. {:phoenix_ecto, "~> 4.4"},
8. {:esbuild, "~> 0.4", runtime: Mix.env() == :dev},
9. {:telemetry_metrics, "~> 0.6"},
10. {:telemetry_poller, "~> 1.0"},
11. {:jason, "~> 1.2"},
12. {:plug_cowboy, "~> 2.5"},
13. {:edgedb, "~> 0.3.0"},
14. {:edgedb_ecto, git: "https://github.com/nsidnev/edgedb_ecto"},
15. {:mint, "~> 1.0"} # we need mint to write the GitHub client
16. ]
17. end
19. # ...
20. 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
7. def child_spec(_opts \\ []) do
8. %{
9. id: __MODULE__,
10. start: {EdgeDB, :start_link, [[name: __MODULE__]]}
11. }
12. end
13. 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. # ...
4. @impl true
5. def start(_type, _args) do
6. children = [
7. # Start the EdgeDB driver
8. GitHubOAuth.EdgeDB,
9. # Start the Telemetry supervisor
10. GitHubOAuthWeb.Telemetry,
11. # Start the PubSub system
12. {Phoenix.PubSub, name: GitHubOAuth.PubSub},
13. # Start the Endpoint (http/https)
14. GitHubOAuthWeb.Endpoint
15. # Start a worker by calling: GitHubOAuth.Worker.start_link(arg)
16. # {GitHubOAuth.Worker, arg}
17. ]
19. # ...
20. end
22. # ...
23. 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
3. Do you want to initialize a new project? [Y/n]
4. > Y
6. Specify the name of EdgeDB instance to use with this project
7. [default: phoenix_github_oauth]:
8. > github_oauth
10. Checking EdgeDB versions...
11. Specify the version of EdgeDB to use with this project [default: 1.x]:
12. > 1.x
14. Do you want to start instance automatically on login? [y/n]
15. > 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;
7. property profile_tagline -> str;
9. property avatar_url -> str;
10. property external_homepage_url -> str;
12. required property inserted_at -> cal::local_datetime {
13. default := cal::to_local_datetime(datetime_current(), 'UTC');
14. }
16. required property updated_at -> cal::local_datetime {
17. default := cal::to_local_datetime(datetime_current(), 'UTC');
18. }
20. index on (.email);
21. index on (.username);
22. }
24. type Identity {
25. required property provider -> str;
26. required property provider_token -> str;
27. required property provider_login -> str;
28. required property provider_email -> str;
29. required property provider_id -> str;
31. required property provider_meta -> json {
32. default := <json>"{}";
33. }
35. required property inserted_at -> cal::local_datetime {
36. default := cal::to_local_datetime(datetime_current(), 'UTC');
37. }
39. required property updated_at -> cal::local_datetime {
40. default := cal::to_local_datetime(datetime_current(), 'UTC');
41. }
43. required link user -> User {
44. on target delete delete source;
45. }
47. index on (.provider);
48. constraint exclusive on ((.user, .provider));
49. }
50. }
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
4. did you create object type 'default::Identity'? [y,n,l,c,b,s,q,?]
5. > y
7. Created ./dbschema/migrations/00001.edgeql, id:
8. 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
5. alias GitHubOAuth.Accounts.Identity
7. @primary_key {:id, :binary_id, autogenerate: false}
9. schema "default::User" do
10. field :email, :string
11. field :name, :string
12. field :username, :string
13. field :avatar_url, :string
14. field :external_homepage_url, :string
16. has_many :identities, Identity
18. timestamps()
19. end
20. end
And here for identity in lib/accounts/identity.ex.
1. defmodule GitHubOAuth.Accounts.Identity do
2. use Ecto.Schema
3. use EdgeDBEcto.Mapper
5. alias GitHubOAuth.Accounts.User
7. @primary_key {:id, :binary_id, autogenerate: false}
9. schema "default::Identity" do
10. field :provider, :string
11. field :provider_token, :string
12. field :provider_email, :string
13. field :provider_login, :string
14. field :provider_name, :string, virtual: true
15. field :provider_id, :string
16. field :provider_meta, :map
18. belongs_to :user, User
20. timestamps()
21. end
22. 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
5. alias GitHubOAuth.Accounts.{Identity, User}
7. @github "github"
9. # ...
11. def github_registration_changeset(info, primary_email, emails, token) do
12. params = %{
13. "provider_token" => token,
14. "provider_id" => to_string(info["id"]),
15. "provider_login" => info["login"],
16. "provider_name" => info["name"] || info["login"],
17. "provider_email" => primary_email
18. }
20. %Identity{}
21. |> cast(params, [
22. :provider_token,
23. :provider_email,
24. :provider_login,
25. :provider_name,
26. :provider_id
27. ])
28. |> put_change(:provider, @github)
29. |> put_change(:provider_meta, %{"user" => info, "emails" => emails})
30. |> validate_required([
31. :provider_token,
32. :provider_email,
33. :provider_name,
34. :provider_id
35. ])
36. end
37. 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. # ...
4. import Ecto.Changeset
6. alias GitHubOAuth.Accounts.{User, Identity}
8. # ...
10. def github_registration_changeset(info, primary_email, emails, token) do
11. %{
12. "login" => username,
13. "avatar_url" => avatar_url,
14. "html_url" => external_homepage_url
15. } = info
17. identity_changeset =
18. Identity.github_registration_changeset(
19. info,
20. primary_email,
21. emails,
22. token
23. )
25. if identity_changeset.valid? do
26. params = %{
27. "username" => username,
28. "email" => primary_email,
29. "name" => get_change(identity_changeset, :provider_name),
30. "avatar_url" => avatar_url,
31. "external_homepage_url" => external_homepage_url
32. }
34. %User{}
35. |> cast(params, [
36. :email,
37. :name,
38. :username,
39. :avatar_url,
40. :external_homepage_url
41. ])
42. |> validate_required([:email, :name, :username])
43. |> validate_username()
44. |> validate_email()
45. |> put_assoc(:identities, [identity_changeset])
46. else
47. %User{}
48. |> change()
49. |> Map.put(:valid?, false)
50. |> put_assoc(:identities, [identity_changeset])
51. end
52. end
54. defp validate_email(changeset) do
55. changeset
56. |> validate_required([:email])
57. |> validate_format(
58. :email,
59. ~r/^[^\s]+@[^\s]+$/,
60. message: "must have the @ sign and no spaces"
61. )
62. |> validate_length(:email, max: 160)
63. end
65. defp validate_username(changeset) do
66. validate_format(changeset, :username, ~r/^[a-zA-Z0-9_-]{2,32}$/)
67. end
68. 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:
- Search for a user by user ID.
- Search the user by email and by identity provider.
- Update the identity token if the user from the 1st query exists.
- Registering a user along with his identity data, if the 1st request did not return the user.
- 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
4. alias GitHubOAuth.Accounts.{User, Identity}
6. def get_user(id) do
7. GitHubOAuth.EdgeDB.Accounts.get_user_by_id(id: id)
8. end
10. def register_github_user(primary_email, info, emails, token) do
11. if user = get_user_by_provider(:github, primary_email) do
12. update_github_token(user, token)
13. else
14. info
15. |> User.github_registration_changeset(primary_email, emails, token)
16. |> EdgeDBEcto.insert(
17. &GitHubOAuth.EdgeDB.Accounts.register_github_user/1,
18. nested: true
19. )
20. end
21. end
23. def get_user_by_provider(provider, email) when provider in [:github] do
24. GitHubOAuth.EdgeDB.Accounts.get_user_by_provider(
25. provider: to_string(provider),
26. email: String.downcase(email)
27. )
28. end
30. defp update_github_token(%User{} = user, new_token) do
31. identity =
32. GitHubOAuth.EdgeDB.Accounts.get_identity_for_user(
33. user_id: user.id,
34. provider: "github"
35. )
37. {:ok, _} =
38. identity
39. |> change()
40. |> put_change(:provider_token, new_token)
41. |> EdgeDBEcto.update(
42. &GitHubOAuth.EdgeDB.Accounts.update_identity_token/1
43. )
45. identity = %Identity{identity | provider_token: new_token}
46. {:ok, %User{user | identities: [identity]}}
47. end
48. 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
4. select User {
5. id,
6. name,
7. username,
8. email,
9. avatar_url,
10. external_homepage_url,
11. inserted_at,
12. updated_at,
13. }
14. filter
15. .<user[is Identity].provider = <str>$provider
16. and
17. str_lower(.email) = str_lower(<str>$email)
18. 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
3. with params := <json>$params
4. update Identity
5. filter .id = <uuid>params["id"]
6. set {
7. provider_token := (
8. <str>json_get(params, "provider_token") ?? .provider_token
9. ),
10. updated_at := cal::to_local_datetime(datetime_current(), 'UTC'),
11. }
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
4. with
5. params := <json>$params,
6. identities_params := params["identities"],
7. user := (
8. insert User {
9. email := <cistr>params["email"],
10. name := <str>params["name"],
11. username := <str>params["username"],
12. avatar_url := <optional str>json_get(params, "avatar_url"),
13. external_homepage_url := (
14. <str>json_get(params, "external_homepage_url")
15. ),
16. }
17. ),
18. identites := (
19. for identity_params in json_array_unpack(identities_params) union (
20. insert Identity {
21. provider := <str>identity_params["provider"],
22. provider_token := <str>identity_params["provider_token"],
23. provider_email := <str>identity_params["provider_email"],
24. provider_login := <str>identity_params["provider_login"],
25. provider_id := <str>identity_params["provider_id"],
26. provider_meta := <json>identity_params["provider_meta"],
27. user := user,
28. }
29. )
30. )
31. select user {
32. id,
33. name,
34. username,
35. email,
36. avatar_url,
37. external_homepage_url,
38. inserted_at,
39. updated_at,
40. identities := identites,
41. }
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. # ...
3. config :github_oauth, :github,
4. client_id: System.fetch_env!("GITHUB_CLIENT_ID"),
5. client_secret: System.fetch_env!("GITHUB_CLIENT_SECRET")
7. # ...
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
4. alias GitHubOAuth.Accounts
6. plug :fetch_current_user
8. def index(conn, _params) do
9. if conn.assigns.current_user do
10. json(conn, %{name: conn.assigns.current_user.name})
11. else
12. redirect(conn, external: GitHubOAuth.GitHub.authorize_url())
13. end
14. end
16. defp fetch_current_user(conn, _opts) do
17. user_id = get_session(conn, :user_id)
18. user = user_id && Accounts.get_user(user_id)
19. assign(conn, :current_user, user)
20. end
21. 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
4. alias GitHubOAuth.Accounts
6. require Logger
8. def new(
9. conn,
10. %{"provider" => "github", "code" => code, "state" => state}
11. ) do
12. client = github_client(conn)
14. with {:ok, info} <-
15. client.exchange_access_token(code: code, state: state),
16. %{
17. info: info,
18. primary_email: primary,
19. emails: emails,
20. token: token
21. } = info,
22. {:ok, user} <-
23. Accounts.register_github_user(primary, info, emails, token) do
24. conn
25. |> log_in_user(user)
26. |> redirect(to: "/")
27. else
28. {:error, %Ecto.Changeset{} = changeset} ->
29. Logger.debug("failed GitHub insert #{inspect(changeset.errors)}")
31. error =
32. "We were unable to fetch the necessary information from " <>
33. "your GitHub account"
35. json(conn, %{error: error})
37. {:error, reason} ->
38. Logger.debug("failed GitHub exchange #{inspect(reason)}")
40. json(conn, %{
41. error: "We were unable to contact GitHub. Please try again later"
42. })
43. end
44. end
46. def new(conn, %{"provider" => "github", "error" => "access_denied"}) do
47. json(conn, %{error: "Access denied"})
48. end
50. defp github_client(conn) do
51. conn.assigns[:github_client] || GitHubOAuth.GitHub
52. end
54. defp log_in_user(conn, user) do
55. conn
56. |> assign(:current_user, user)
57. |> configure_session(renew: true)
58. |> clear_session()
59. |> put_session(:user_id, user.id)
60. end
61. end
Finally, we need to change lib/github_oauth_web/router.ex and add new controllers there.
1. defmodule GitHubOAuthWeb.Router do
2. # ...
4. pipeline :api do
5. # ...
6. plug :fetch_session
7. end
9. scope "/", GitHubOAuthWeb do
10. pipe_through :api
12. get "/", UserController, :index
13. get "/oauth/callbacks/:provider", OAuthCallbackController, :new
14. end
16. # ...
17. 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)
5. [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'}}
