Deployment and lifecycle management for Nix
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

working oidc login for users

+433 -22
-6
.envrc
··· 10 10 export RELEASE_COOKIE=$(cat $RELEASE_COOKIE_FILE) 11 11 12 12 export ERL_AFLAGS="-kernel shell_history enabled -kernel shell_history_file_bytes 1024000" 13 - 14 - export SOWER_BOOTSTRAP_TOKEN_FILE="$PWD/.bootstrap.token" 15 - if [ ! -f "$SOWER_BOOTSTRAP_TOKEN_FILE" ]; then 16 - echo "Creating development bootstrap token file" 17 - dd if=/dev/urandom bs=1 count=64 | hexdump -e '64/1 "%02x"' >"$SOWER_BOOTSTRAP_TOKEN_FILE" 18 - fi
+2
.formatter.exs
··· 1 1 [ 2 2 import_deps: [ 3 3 :ash, 4 + :ash_authentication, 5 + :ash_authentication_phoenix, 4 6 :ash_json_api, 5 7 :ash_phoenix, 6 8 :ash_postgres,
+1 -1
config/config.exs
··· 8 8 import Config 9 9 10 10 config :sower, ecto_repos: [Sower.Repo] 11 - config :sower, ash_domains: [Sower] 11 + config :sower, ash_domains: [Sower, Sower.Accounts] 12 12 13 13 # Configures the endpoint 14 14 config :sower, SowerWeb.Endpoint,
+8 -3
config/dev.exs
··· 1 1 import Config 2 2 3 + config :sower, 4 + dev_routes: true, 5 + bootstrap_token: System.get_env("SOWER_BOOTSTRAP_TOKEN"), 6 + oidc_base_url: System.get_env("SOWER_AUTH_OIDC_BASE_URL"), 7 + oidc_client_id: System.get_env("SOWER_AUTH_OIDC_CLIENT_ID"), 8 + oidc_client_secret: System.get_env("SOWER_AUTH_OIDC_CLIENT_SECRET"), 9 + oidc_redirect_uri: "http://localhost:4000/auth" 10 + 3 11 # Configure your database 4 12 config :sower, Sower.Repo, 5 13 username: "postgres", ··· 63 71 ~r"lib/git/.*ex$" 64 72 ] 65 73 ] 66 - 67 - # Enable dev routes for dashboard and mailbox 68 - config :sower, dev_routes: true 69 74 70 75 # Do not include metadata nor timestamps in development logs 71 76 config :logger, :console, format: "[$level] $message\n"
+14 -5
config/runtime.exs
··· 20 20 config :sower, SowerWeb.Endpoint, server: true 21 21 end 22 22 23 - config :sower, 24 - bootstrap_token: Sower.Application.credential!("SOWER_BOOTSTRAP_TOKEN_FILE") 23 + config :sower, oidc_base_url: System.get_env("SOWER_AUTH_OIDC_BASE_URL") 25 24 26 25 if config_env() == :prod do 26 + host = System.get_env("SOWER_HOSTNAME") || raise "missing $SOWER_HOSTNAME" 27 + scheme = System.get_env("SOWER_PUBLIC_SCHEME", "https") 28 + public_port = String.to_integer(System.get_env("SOWER_PUBLIC_PORT", "443")) 29 + 30 + config :sower, 31 + bootstrap_token: Sower.Application.credential!("SOWER_BOOTSTRAP_TOKEN_FILE"), 32 + oidc_base_url: 33 + System.get_env("SOWER_AUTH_OIDC_BASE_URL") || raise("missing $SOWER_AUTH_OIDC_BASE_URL"), 34 + oidc_client_id: Sower.Application.credential!("SOWER_AUTH_OIDC_CLIENT_ID_FILE"), 35 + oidc_client_secret: Sower.Application.credential!("SOWER_AUTH_OIDC_CLIENT_ID_FILE"), 36 + oidc_redirect_uri: 37 + System.get_env("SOWER_AUTH_OIDC_REDIRECT_URI", ~s"#{scheme}://#{host}:#{public_port}/auth") 38 + 27 39 if System.get_env() |> Map.has_key?("SOWER_DATABASE_SOCKET") do 28 40 config :sower, Sower.Repo, 29 41 socket: System.get_env("SOWER_DATABASE_SOCKET"), ··· 44 56 # variable instead. 45 57 secret_key_base = Sower.Application.credential!("SECRET_KEY_BASE_FILE") 46 58 47 - host = System.get_env("SOWER_HOSTNAME") || raise "missing $SOWER_HOSTNAME" 48 59 port = String.to_integer(System.get_env("SOWER_LISTEN_PORT", "4000")) 49 - scheme = System.get_env("SOWER_PUBLIC_SCHEME", "https") 50 - public_port = String.to_integer(System.get_env("SOWER_PUBLIC_PORT", "443")) 51 60 52 61 {:ok, listen_ip} = 53 62 System.get_env("SOWER_LISTEN_ADDRESS", "127.0.0.1")
+8
lib/sower/accounts/account.ex
··· 1 + defmodule Sower.Accounts do 2 + use Ash.Domain 3 + 4 + resources do 5 + resource Sower.Accounts.User 6 + resource Sower.Accounts.UserToken 7 + end 8 + end
+21
lib/sower/accounts/secrets.ex
··· 1 + defmodule Sower.Accounts.Secrets do 2 + use AshAuthentication.Secret 3 + 4 + def secret_for([:authentication, :tokens, :signing_secret], Sower.Accounts.User, _) do 5 + case Application.fetch_env(:example, ExampleWeb.Endpoint) do 6 + {:ok, endpoint_config} -> 7 + Keyword.fetch(endpoint_config, :secret_key_base) 8 + 9 + :error -> 10 + :error 11 + end 12 + end 13 + 14 + def secret_for([:authentication, :strategies, :oidc, :client_id], Sower.Accounts.User, _) do 15 + Application.fetch_env(:sower, :oidc_client_id) 16 + end 17 + 18 + def secret_for([:authentication, :strategies, :oidc, :client_secret], Sower.Accounts.User, _) do 19 + Application.fetch_env(:sower, :oidc_client_secret) 20 + end 21 + end
+11
lib/sower/accounts/token.ex
··· 1 + defmodule Sower.Accounts.UserToken do 2 + use Ash.Resource, 3 + domain: Sower.Accounts, 4 + data_layer: AshPostgres.DataLayer, 5 + extensions: [AshAuthentication.TokenResource] 6 + 7 + postgres do 8 + table "user_tokens" 9 + repo Sower.Repo 10 + end 11 + end
+68
lib/sower/accounts/user.ex
··· 1 + defmodule Sower.Accounts.User do 2 + use Ash.Resource, 3 + domain: Sower.Accounts, 4 + data_layer: AshPostgres.DataLayer, 5 + extensions: [AshAuthentication] 6 + 7 + actions do 8 + defaults [:read] 9 + 10 + create :register_with_oidc do 11 + argument :user_info, :map, allow_nil?: false 12 + argument :oauth_tokens, :map, allow_nil?: false 13 + upsert? true 14 + upsert_identity :unique_oidc_id 15 + 16 + change AshAuthentication.GenerateTokenChange 17 + 18 + change fn changeset, _ctx -> 19 + user_info = Ash.Changeset.get_argument(changeset, :user_info) 20 + 21 + changeset 22 + |> Ash.Changeset.change_attribute(:oidc_id, user_info["sub"]) 23 + |> Ash.Changeset.change_attribute(:username, user_info["preferred_username"]) 24 + end 25 + end 26 + end 27 + 28 + attributes do 29 + uuid_primary_key :id 30 + 31 + attribute :oidc_id, :uuid do 32 + allow_nil? false 33 + end 34 + 35 + attribute :username, :ci_string do 36 + allow_nil? false 37 + public? true 38 + end 39 + end 40 + 41 + authentication do 42 + strategies do 43 + oidc :oidc do 44 + client_id Sower.Accounts.Secrets 45 + base_url fn _, _ -> Application.fetch_env(:sower, :oidc_base_url) end 46 + redirect_uri fn _, _ -> Application.fetch_env(:sower, :oidc_redirect_uri) end 47 + client_secret Sower.Accounts.Secrets 48 + # TODO: figure out why decoding fails with ES256 49 + # id_token_signed_response_alg "ES256" 50 + # authorization_params scope: "openid profile email" 51 + end 52 + end 53 + 54 + tokens do 55 + token_resource Sower.Accounts.UserToken 56 + signing_secret Sower.Accounts.Secrets 57 + end 58 + end 59 + 60 + postgres do 61 + table "users" 62 + repo Sower.Repo 63 + end 64 + 65 + identities do 66 + identity :unique_oidc_id, [:oidc_id] 67 + end 68 + end
+1
lib/sower/application.ex
··· 8 8 children = [ 9 9 SowerWeb.Telemetry, 10 10 Sower.Repo, 11 + {AshAuthentication.Supervisor, otp_app: :sower}, 11 12 {Phoenix.PubSub, name: Sower.PubSub}, 12 13 {Finch, name: Sower.Finch}, 13 14 SowerWeb.Endpoint,
+1 -1
lib/sower_web.ex
··· 21 21 22 22 def router do 23 23 quote do 24 - use Phoenix.Router, helpers: false 24 + use Phoenix.Router, helpers: true 25 25 26 26 # Import common connection and controller functions to use in pipelines 27 27 import Plug.Conn
+19
lib/sower_web/components/layouts/app.html.heex
··· 22 22 > 23 23 Repos 24 24 </.link> 25 + 26 + <%= if @current_user do %> 27 + <span class="px-3 py-2 text-sm font-medium text-white rounded-md"> 28 + <%= @current_user.username %> 29 + </span> 30 + <a 31 + href="/sign-out" 32 + class="rounded-lg bg-zinc-100 px-2 py-1 text-[0.8125rem] font-semibold leading-6 text-zinc-900 hover:bg-zinc-200/80 active:text-zinc-900/70" 33 + > 34 + Sign out 35 + </a> 36 + <% else %> 37 + <a 38 + href="/auth/user/oidc" 39 + class="rounded-lg bg-zinc-100 px-2 py-1 text-[0.8125rem] font-semibold leading-6 text-zinc-900 hover:bg-zinc-200/80 active:text-zinc-900/70" 40 + > 41 + Sign In 42 + </a> 43 + <% end %> 25 44 </div> 26 45 </div> 27 46 </header>
+30
lib/sower_web/controllers/auth_controller.ex
··· 1 + defmodule SowerWeb.AuthController do 2 + use SowerWeb, :controller 3 + use AshAuthentication.Phoenix.Controller 4 + 5 + def success(conn, _activity, user, _token) do 6 + return_to = get_session(conn, :return_to) || ~p"/" 7 + 8 + conn 9 + |> delete_session(:return_to) 10 + |> store_in_session(user) 11 + |> assign(:current_user, user) 12 + |> redirect(to: return_to) 13 + end 14 + 15 + def failure(conn, _activity, reason) do 16 + dbg(reason) 17 + 18 + conn 19 + |> put_flash(:error, "Incorrect email or password") 20 + |> redirect(to: ~p"/sign-in") 21 + end 22 + 23 + def sign_out(conn, _params) do 24 + return_to = get_session(conn, :return_to) || ~p"/" 25 + 26 + conn 27 + |> clear_session() 28 + |> redirect(to: return_to) 29 + end 30 + end
+5
lib/sower_web/controllers/auth_html.ex
··· 1 + defmodule SowerWeb.AuthHTML do 2 + use SowerWeb, :html 3 + 4 + embed_templates "auth_html/*" 5 + end
+1
lib/sower_web/controllers/auth_html/failure.html.heex
··· 1 + <h1 class="text-2xl">Authentication Error</h1>
+32
lib/sower_web/live_user_auth.ex
··· 1 + defmodule SowerWeb.LiveUserAuth do 2 + @moduledoc """ 3 + Helpers for authenticating users in LiveViews. 4 + """ 5 + 6 + import Phoenix.Component 7 + use SowerWeb, :verified_routes 8 + 9 + def on_mount(:live_user_optional, _params, _session, socket) do 10 + if socket.assigns[:current_user] do 11 + {:cont, socket} 12 + else 13 + {:cont, assign(socket, :current_user, nil)} 14 + end 15 + end 16 + 17 + def on_mount(:live_user_required, _params, _session, socket) do 18 + if socket.assigns[:current_user] do 19 + {:cont, socket} 20 + else 21 + {:halt, Phoenix.LiveView.redirect(socket, to: ~p"/auth/user/oidc")} 22 + end 23 + end 24 + 25 + def on_mount(:live_no_user, _params, _session, socket) do 26 + if socket.assigns[:current_user] do 27 + {:halt, Phoenix.LiveView.redirect(socket, to: ~p"/")} 28 + else 29 + {:cont, assign(socket, :current_user, nil)} 30 + end 31 + end 32 + end
+20 -6
lib/sower_web/router.ex
··· 1 1 defmodule SowerWeb.Router do 2 2 use SowerWeb, :router 3 3 use Plug.ErrorHandler 4 + use AshAuthentication.Phoenix.Router 4 5 5 6 pipeline :browser do 6 7 plug :accepts, ["html"] ··· 9 10 plug :put_root_layout, html: {SowerWeb.Layouts, :root} 10 11 plug :protect_from_forgery 11 12 plug :put_secure_browser_headers 13 + plug :load_from_session 12 14 end 13 15 14 16 pipeline :api do 15 17 plug :accepts, ["json"] 18 + plug :load_from_bearer 16 19 end 17 20 18 21 scope "/", SowerWeb do ··· 20 23 21 24 get "/", PageController, :home 22 25 23 - live "/seeds", SeedLive.Index, :index 24 - live "/seeds/:id", SeedLive.Show, :show 25 - live "/trees", TreeLive.Index, :index 26 - live "/trees/:id", TreeLive.Show, :show 27 - live "/inputs/repos", RepositoryLive.Index, :index 28 - live "/inputs/repos/:id", RepositoryLive.Show, :show 26 + sign_in_route(register_path: "/register") 27 + sign_out_route AuthController 28 + auth_routes_for Sower.Accounts.User, to: AuthController 29 + 30 + ash_authentication_live_session :authentication_required, 31 + on_mount: {SowerWeb.LiveUserAuth, :live_user_required} do 32 + live "/seeds", SeedLive.Index, :index 33 + live "/seeds/:id", SeedLive.Show, :show 34 + live "/trees", TreeLive.Index, :index 35 + live "/trees/:id", TreeLive.Show, :show 36 + live "/inputs/repos", RepositoryLive.Index, :index 37 + live "/inputs/repos/:id", RepositoryLive.Show, :show 38 + end 39 + 40 + ash_authentication_live_session :authentication_optional, 41 + on_mount: {SowerWeb.LiveUserAuth, :live_user_optional} do 42 + end 29 43 end 30 44 31 45 scope "/api" do
+43
priv/repo/migrations/20240521170535_add_accounts_user.exs
··· 1 + defmodule Sower.Repo.Migrations.AddAccountsUser do 2 + @moduledoc """ 3 + Updates resources based on their most recent snapshots. 4 + 5 + This file was autogenerated with `mix ash_postgres.generate_migrations` 6 + """ 7 + 8 + use Ecto.Migration 9 + 10 + def up do 11 + create table(:users, primary_key: false) do 12 + add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true 13 + add :oidc_id, :uuid, null: false 14 + add :username, :citext, null: false 15 + end 16 + 17 + create unique_index(:users, [:oidc_id], name: "users_unique_oidc_id_index") 18 + 19 + create table(:user_tokens, primary_key: false) do 20 + add :updated_at, :utc_datetime_usec, 21 + null: false, 22 + default: fragment("(now() AT TIME ZONE 'utc')") 23 + 24 + add :created_at, :utc_datetime_usec, 25 + null: false, 26 + default: fragment("(now() AT TIME ZONE 'utc')") 27 + 28 + add :extra_data, :map 29 + add :purpose, :text, null: false 30 + add :expires_at, :utc_datetime, null: false 31 + add :subject, :text, null: false 32 + add :jti, :text, null: false, primary_key: true 33 + end 34 + end 35 + 36 + def down do 37 + drop table(:user_tokens) 38 + 39 + drop_if_exists unique_index(:users, [:oidc_id], name: "users_unique_oidc_id_index") 40 + 41 + drop table(:users) 42 + end 43 + end
+89
priv/resource_snapshots/repo/user_tokens/20240521170535.json
··· 1 + { 2 + "attributes": [ 3 + { 4 + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", 5 + "size": null, 6 + "type": "utc_datetime_usec", 7 + "source": "updated_at", 8 + "references": null, 9 + "allow_nil?": false, 10 + "primary_key?": false, 11 + "generated?": false 12 + }, 13 + { 14 + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", 15 + "size": null, 16 + "type": "utc_datetime_usec", 17 + "source": "created_at", 18 + "references": null, 19 + "allow_nil?": false, 20 + "primary_key?": false, 21 + "generated?": false 22 + }, 23 + { 24 + "default": "nil", 25 + "size": null, 26 + "type": "map", 27 + "source": "extra_data", 28 + "references": null, 29 + "allow_nil?": true, 30 + "primary_key?": false, 31 + "generated?": false 32 + }, 33 + { 34 + "default": "nil", 35 + "size": null, 36 + "type": "text", 37 + "source": "purpose", 38 + "references": null, 39 + "allow_nil?": false, 40 + "primary_key?": false, 41 + "generated?": false 42 + }, 43 + { 44 + "default": "nil", 45 + "size": null, 46 + "type": "utc_datetime", 47 + "source": "expires_at", 48 + "references": null, 49 + "allow_nil?": false, 50 + "primary_key?": false, 51 + "generated?": false 52 + }, 53 + { 54 + "default": "nil", 55 + "size": null, 56 + "type": "text", 57 + "source": "subject", 58 + "references": null, 59 + "allow_nil?": false, 60 + "primary_key?": false, 61 + "generated?": false 62 + }, 63 + { 64 + "default": "nil", 65 + "size": null, 66 + "type": "text", 67 + "source": "jti", 68 + "references": null, 69 + "allow_nil?": false, 70 + "primary_key?": true, 71 + "generated?": false 72 + } 73 + ], 74 + "table": "user_tokens", 75 + "hash": "DD6D62D862B598BD3A0A5D0C1E42F723F416D71A4728DE52958B8E1CFA4B418A", 76 + "repo": "Elixir.Sower.Repo", 77 + "identities": [], 78 + "schema": null, 79 + "check_constraints": [], 80 + "custom_indexes": [], 81 + "multitenancy": { 82 + "global": null, 83 + "strategy": null, 84 + "attribute": null 85 + }, 86 + "base_filter": null, 87 + "custom_statements": [], 88 + "has_create_action": true 89 + }
+59
priv/resource_snapshots/repo/users/20240521170535.json
··· 1 + { 2 + "attributes": [ 3 + { 4 + "default": "fragment(\"gen_random_uuid()\")", 5 + "size": null, 6 + "type": "uuid", 7 + "source": "id", 8 + "references": null, 9 + "allow_nil?": false, 10 + "primary_key?": true, 11 + "generated?": false 12 + }, 13 + { 14 + "default": "nil", 15 + "size": null, 16 + "type": "uuid", 17 + "source": "oidc_id", 18 + "references": null, 19 + "allow_nil?": false, 20 + "primary_key?": false, 21 + "generated?": false 22 + }, 23 + { 24 + "default": "nil", 25 + "size": null, 26 + "type": "citext", 27 + "source": "username", 28 + "references": null, 29 + "allow_nil?": false, 30 + "primary_key?": false, 31 + "generated?": false 32 + } 33 + ], 34 + "table": "users", 35 + "hash": "5B0DF778D20B57213F567B20A116AB8162BBFF3ED5C30D4212C3947CBCAE20A0", 36 + "repo": "Elixir.Sower.Repo", 37 + "identities": [ 38 + { 39 + "name": "unique_oidc_id", 40 + "keys": [ 41 + "oidc_id" 42 + ], 43 + "base_filter": null, 44 + "all_tenants?": false, 45 + "index_name": "users_unique_oidc_id_index" 46 + } 47 + ], 48 + "schema": null, 49 + "check_constraints": [], 50 + "custom_indexes": [], 51 + "multitenancy": { 52 + "global": null, 53 + "strategy": null, 54 + "attribute": null 55 + }, 56 + "base_filter": null, 57 + "custom_statements": [], 58 + "has_create_action": true 59 + }