From ce8bb227580490d379db45dd88c169ebafb156b5 Mon Sep 17 00:00:00 2001 From: "Elias W. BA" Date: Thu, 4 Dec 2025 20:58:34 +0000 Subject: [PATCH 1/3] Add demo OAuth clients for Google, Salesforce, and Microsoft This adds global OAuth clients for common integrations during demo setup: Google services: - Google Drive - Google Sheets - Gmail Salesforce: - Salesforce (production) - Salesforce Sandbox Microsoft services: - Microsoft SharePoint - Microsoft Outlook - Microsoft Calendar - Microsoft OneDrive - Microsoft Teams Features: - OAuth client credentials are read from environment variables via Bootstrap - Falls back to dummy placeholder values for development/testing - Clients are owned by super user if available, otherwise admin - All clients are global (available across all projects) - Scopes use full URL format for Google to match token response Environment variables follow the pattern: - {SERVICE}_CLIENT_ID - {SERVICE}_CLIENT_SECRET --- lib/lightning/config/bootstrap.ex | 46 ++++++++ lib/lightning/setup_utils.ex | 189 +++++++++++++++++++++++++++++- 2 files changed, 234 insertions(+), 1 deletion(-) diff --git a/lib/lightning/config/bootstrap.ex b/lib/lightning/config/bootstrap.ex index 490e429413..5d6f4caaf4 100644 --- a/lib/lightning/config/bootstrap.ex +++ b/lib/lightning/config/bootstrap.ex @@ -717,6 +717,8 @@ defmodule Lightning.Config.Bootstrap do "lightning-cluster" ) + setup_demo_oauth_clients() + # ============================================================================== setup_storage() @@ -900,4 +902,48 @@ defmodule Lightning.Config.Bootstrap do value -> value end end + + defp setup_demo_oauth_clients do + config :lightning, :demo_oauth_clients, + google_drive: [ + client_id: env!("GOOGLE_DRIVE_CLIENT_ID", :string, nil), + client_secret: env!("GOOGLE_DRIVE_CLIENT_SECRET", :string, nil) + ], + google_sheets: [ + client_id: env!("GOOGLE_SHEETS_CLIENT_ID", :string, nil), + client_secret: env!("GOOGLE_SHEETS_CLIENT_SECRET", :string, nil) + ], + gmail: [ + client_id: env!("GMAIL_CLIENT_ID", :string, nil), + client_secret: env!("GMAIL_CLIENT_SECRET", :string, nil) + ], + salesforce: [ + client_id: env!("SALESFORCE_CLIENT_ID", :string, nil), + client_secret: env!("SALESFORCE_CLIENT_SECRET", :string, nil) + ], + salesforce_sandbox: [ + client_id: env!("SALESFORCE_SANDBOX_CLIENT_ID", :string, nil), + client_secret: env!("SALESFORCE_SANDBOX_CLIENT_SECRET", :string, nil) + ], + microsoft_sharepoint: [ + client_id: env!("MICROSOFT_SHAREPOINT_CLIENT_ID", :string, nil), + client_secret: env!("MICROSOFT_SHAREPOINT_CLIENT_SECRET", :string, nil) + ], + microsoft_outlook: [ + client_id: env!("MICROSOFT_OUTLOOK_CLIENT_ID", :string, nil), + client_secret: env!("MICROSOFT_OUTLOOK_CLIENT_SECRET", :string, nil) + ], + microsoft_calendar: [ + client_id: env!("MICROSOFT_CALENDAR_CLIENT_ID", :string, nil), + client_secret: env!("MICROSOFT_CALENDAR_CLIENT_SECRET", :string, nil) + ], + microsoft_onedrive: [ + client_id: env!("MICROSOFT_ONEDRIVE_CLIENT_ID", :string, nil), + client_secret: env!("MICROSOFT_ONEDRIVE_CLIENT_SECRET", :string, nil) + ], + microsoft_teams: [ + client_id: env!("MICROSOFT_TEAMS_CLIENT_ID", :string, nil), + client_secret: env!("MICROSOFT_TEAMS_CLIENT_SECRET", :string, nil) + ] + end end diff --git a/lib/lightning/setup_utils.ex b/lib/lightning/setup_utils.ex index 86d57d498a..162d12c15d 100644 --- a/lib/lightning/setup_utils.ex +++ b/lib/lightning/setup_utils.ex @@ -9,6 +9,7 @@ defmodule Lightning.SetupUtils do alias Lightning.Accounts.User alias Lightning.Credentials alias Lightning.Jobs + alias Lightning.OauthClients alias Lightning.Projects alias Lightning.Repo alias Lightning.Runs @@ -40,6 +41,7 @@ defmodule Lightning.SetupUtils do @spec setup_demo(nil | maybe_improper_list | map) :: %{ jobs: [...], + oauth_clients: map(), projects: [atom | %{:id => any, optional(any) => any}, ...], users: [atom | %{:id => any, optional(any) => any}, ...], workflows: [atom | %{:id => any, optional(any) => any}, ...], @@ -52,6 +54,9 @@ defmodule Lightning.SetupUtils do %{super_user: super_user, admin: admin, editor: editor, viewer: viewer} = create_users(opts) |> confirm_users() + # Create demo OAuth clients owned by super user if available, otherwise admin + oauth_clients = create_demo_oauth_clients(super_user || admin) + %{ project: openhie_project, workflow: openhie_workflow, @@ -83,7 +88,8 @@ defmodule Lightning.SetupUtils do workorders: [ openhie_workorder, failure_dhis2_workorder - ] + ], + oauth_clients: oauth_clients } end @@ -115,6 +121,187 @@ defmodule Lightning.SetupUtils do credential end + @doc """ + Creates demo OAuth clients for Google (Drive, Sheets, Gmail), Salesforce, and Microsoft + (SharePoint, Outlook, Calendar, OneDrive, Teams). + + These are global OAuth clients that can be used across all projects. + Client IDs and secrets are read from application configuration (set via environment + variables in `config/runtime.exs`), falling back to dummy placeholder values for + development/testing. + + ## Environment Variables + + Each service reads from two environment variables: + - `{SERVICE}_CLIENT_ID` - The OAuth client ID + - `{SERVICE}_CLIENT_SECRET` - The OAuth client secret + + Where `{SERVICE}` is one of: + - `GOOGLE_DRIVE`, `GOOGLE_SHEETS`, `GMAIL` + - `SALESFORCE` + - `MICROSOFT_SHAREPOINT`, `MICROSOFT_OUTLOOK`, `MICROSOFT_CALENDAR`, + `MICROSOFT_ONEDRIVE`, `MICROSOFT_TEAMS` + + ## Parameters + - user: The user who will own the OAuth clients. + + ## Returns + - A map containing the created OAuth clients keyed by provider name. + """ + def create_demo_oauth_clients(%Accounts.User{id: user_id}) do + oauth_clients = [ + # Google services + google_oauth_client( + "Google Drive", + :google_drive, + "openid,https://www.googleapis.com/auth/userinfo.email,https://www.googleapis.com/auth/userinfo.profile,https://www.googleapis.com/auth/drive", + user_id + ), + google_oauth_client( + "Google Sheets", + :google_sheets, + "openid,https://www.googleapis.com/auth/userinfo.email,https://www.googleapis.com/auth/userinfo.profile,https://www.googleapis.com/auth/spreadsheets", + user_id + ), + google_oauth_client( + "Gmail", + :gmail, + "openid,https://www.googleapis.com/auth/userinfo.email,https://www.googleapis.com/auth/userinfo.profile,https://www.googleapis.com/auth/gmail.readonly,https://www.googleapis.com/auth/gmail.send", + user_id + ), + # Salesforce + salesforce_oauth_client( + "Salesforce", + :salesforce, + "login.salesforce.com", + user_id + ), + salesforce_oauth_client( + "Salesforce Sandbox", + :salesforce_sandbox, + "test.salesforce.com", + user_id + ), + # Microsoft services + microsoft_oauth_client( + "Microsoft SharePoint", + :microsoft_sharepoint, + "openid,email,profile,offline_access,Sites.Read.All,Sites.ReadWrite.All", + user_id + ), + microsoft_oauth_client( + "Microsoft Outlook", + :microsoft_outlook, + "openid,email,profile,offline_access,Mail.Read,Mail.Send", + user_id + ), + microsoft_oauth_client( + "Microsoft Calendar", + :microsoft_calendar, + "openid,email,profile,offline_access,Calendars.Read,Calendars.ReadWrite", + user_id + ), + microsoft_oauth_client( + "Microsoft OneDrive", + :microsoft_onedrive, + "openid,email,profile,offline_access,Files.Read,Files.ReadWrite", + user_id + ), + microsoft_oauth_client( + "Microsoft Teams", + :microsoft_teams, + "openid,email,profile,offline_access,Team.ReadBasic.All,Channel.ReadBasic.All,Chat.Read", + user_id + ) + ] + + oauth_clients + |> Enum.reduce(%{}, fn client_attrs, acc -> + {:ok, client} = OauthClients.create_client(client_attrs) + + key = + client_attrs.name + |> String.downcase() + |> String.replace(" ", "_") + |> String.to_atom() + + Map.put(acc, key, client) + end) + end + + defp google_oauth_client(name, config_key, mandatory_scopes, user_id) do + {client_id, client_secret} = get_oauth_credentials(config_key) + + %{ + name: name, + client_id: client_id, + client_secret: client_secret, + authorization_endpoint: "https://accounts.google.com/o/oauth2/v2/auth", + token_endpoint: "https://oauth2.googleapis.com/token", + revocation_endpoint: "https://oauth2.googleapis.com/revoke", + userinfo_endpoint: "https://openidconnect.googleapis.com/v1/userinfo", + scopes_doc_url: + "https://developers.google.com/identity/protocols/oauth2/scopes", + mandatory_scopes: mandatory_scopes, + global: true, + user_id: user_id + } + end + + defp salesforce_oauth_client(name, config_key, domain, user_id) do + {client_id, client_secret} = get_oauth_credentials(config_key) + + %{ + name: name, + client_id: client_id, + client_secret: client_secret, + authorization_endpoint: "https://#{domain}/services/oauth2/authorize", + token_endpoint: "https://#{domain}/services/oauth2/token", + revocation_endpoint: "https://#{domain}/services/oauth2/revoke", + userinfo_endpoint: "https://#{domain}/services/oauth2/userinfo", + scopes_doc_url: + "https://help.salesforce.com/s/articleView?id=sf.remoteaccess_oauth_tokens_scopes.htm", + mandatory_scopes: "openid,api,refresh_token,full", + global: true, + user_id: user_id + } + end + + defp microsoft_oauth_client(name, config_key, mandatory_scopes, user_id) do + {client_id, client_secret} = get_oauth_credentials(config_key) + + %{ + name: name, + client_id: client_id, + client_secret: client_secret, + authorization_endpoint: + "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", + token_endpoint: + "https://login.microsoftonline.com/common/oauth2/v2.0/token", + revocation_endpoint: + "https://login.microsoftonline.com/common/oauth2/v2.0/logout", + userinfo_endpoint: "https://graph.microsoft.com/oidc/userinfo", + scopes_doc_url: + "https://learn.microsoft.com/en-us/entra/identity-platform/scopes-oidc", + mandatory_scopes: mandatory_scopes, + global: true, + user_id: user_id + } + end + + defp get_oauth_credentials(config_key) do + default_id = "demo-#{config_key}-client-id" + default_secret = "demo-#{config_key}-client-secret" + + config = Application.get_env(:lightning, :demo_oauth_clients, []) + client_config = Keyword.get(config, config_key, []) + + client_id = Keyword.get(client_config, :client_id) || default_id + client_secret = Keyword.get(client_config, :client_secret) || default_secret + + {client_id, client_secret} + end + def create_users(opts) do super_user = if opts[:create_super] do From 2dd94b6d88d6a4f66c8843911b0ac7961c3037f1 Mon Sep 17 00:00:00 2001 From: "Elias W. BA" Date: Thu, 4 Dec 2025 21:11:41 +0000 Subject: [PATCH 2/3] Add changelog entry for demo OAuth clients --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index de3e5d41d0..04c82f8ffb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,11 @@ and this project adheres to ### Added +- Demo OAuth clients for Google (Drive, Sheets, Gmail), Salesforce (production + and sandbox), and Microsoft (SharePoint, Outlook, Calendar, OneDrive, Teams) + are now created during demo setup with configurable credentials via + environment variables + ### Changed ### Fixed From ed0521186c5c3285a58165dac200dd0bab57296d Mon Sep 17 00:00:00 2001 From: "Elias W. BA" Date: Thu, 4 Dec 2025 21:20:22 +0000 Subject: [PATCH 3/3] Add mix task for setting up demo OAuth clients Add `mix lightning.setup_demo_oauth_clients` task that allows users to create demo OAuth clients without requiring a database reset. Features: - Creates OAuth clients for Google, Salesforce, and Microsoft services - Skips existing OAuth clients (idempotent operation) - Supports --email option to specify the OAuth client owner - Falls back to superuser or first user if no email specified - Clear output showing created vs skipped clients This enables adding OAuth clients to existing deployments without running `mix ecto.reset` or the full demo setup. --- lib/lightning/setup_utils.ex | 151 ++++++++++++++++++++++ lib/mix/tasks/setup_demo_oauth_clients.ex | 135 +++++++++++++++++++ 2 files changed, 286 insertions(+) create mode 100644 lib/mix/tasks/setup_demo_oauth_clients.ex diff --git a/lib/lightning/setup_utils.ex b/lib/lightning/setup_utils.ex index 162d12c15d..309f0f67b1 100644 --- a/lib/lightning/setup_utils.ex +++ b/lib/lightning/setup_utils.ex @@ -39,6 +39,157 @@ defmodule Lightning.SetupUtils do end end + @doc """ + Creates demo OAuth clients for an existing user. + + This function can be called independently without resetting the database. + It will skip creating OAuth clients that already exist (by name). + + ## Parameters + - opts: Keyword list with options + - `:user_email` - Email of the user who will own the OAuth clients. + If not provided, uses the first superuser found, or falls back to + the first user in the system. + + ## Returns + - `{:ok, map}` with created OAuth clients keyed by provider name + - `{:error, reason}` if no user is found + + ## Examples + + # Use default user (first superuser or first user) + Lightning.SetupUtils.setup_demo_oauth_clients() + + # Specify a user by email + Lightning.SetupUtils.setup_demo_oauth_clients(user_email: "admin@example.com") + """ + def setup_demo_oauth_clients(opts \\ []) do + case find_oauth_client_owner(opts[:user_email]) do + {:ok, user} -> + oauth_clients = create_demo_oauth_clients_if_not_exists(user) + {:ok, oauth_clients} + + {:error, reason} -> + {:error, reason} + end + end + + defp create_demo_oauth_clients_if_not_exists(%Accounts.User{id: user_id}) do + existing_names = get_existing_oauth_client_names() + + oauth_client_attrs = [ + # Google services + google_oauth_client( + "Google Drive", + :google_drive, + "openid,https://www.googleapis.com/auth/userinfo.email,https://www.googleapis.com/auth/userinfo.profile,https://www.googleapis.com/auth/drive", + user_id + ), + google_oauth_client( + "Google Sheets", + :google_sheets, + "openid,https://www.googleapis.com/auth/userinfo.email,https://www.googleapis.com/auth/userinfo.profile,https://www.googleapis.com/auth/spreadsheets", + user_id + ), + google_oauth_client( + "Gmail", + :gmail, + "openid,https://www.googleapis.com/auth/userinfo.email,https://www.googleapis.com/auth/userinfo.profile,https://www.googleapis.com/auth/gmail.readonly,https://www.googleapis.com/auth/gmail.send", + user_id + ), + # Salesforce + salesforce_oauth_client( + "Salesforce", + :salesforce, + "login.salesforce.com", + user_id + ), + salesforce_oauth_client( + "Salesforce Sandbox", + :salesforce_sandbox, + "test.salesforce.com", + user_id + ), + # Microsoft services + microsoft_oauth_client( + "Microsoft SharePoint", + :microsoft_sharepoint, + "openid,email,profile,offline_access,Sites.Read.All,Sites.ReadWrite.All", + user_id + ), + microsoft_oauth_client( + "Microsoft Outlook", + :microsoft_outlook, + "openid,email,profile,offline_access,Mail.Read,Mail.Send", + user_id + ), + microsoft_oauth_client( + "Microsoft Calendar", + :microsoft_calendar, + "openid,email,profile,offline_access,Calendars.Read,Calendars.ReadWrite", + user_id + ), + microsoft_oauth_client( + "Microsoft OneDrive", + :microsoft_onedrive, + "openid,email,profile,offline_access,Files.Read,Files.ReadWrite", + user_id + ), + microsoft_oauth_client( + "Microsoft Teams", + :microsoft_teams, + "openid,email,profile,offline_access,Team.ReadBasic.All,Channel.ReadBasic.All,Chat.Read", + user_id + ) + ] + + oauth_client_attrs + |> Enum.reduce(%{}, fn client_attrs, acc -> + key = + client_attrs.name + |> String.downcase() + |> String.replace(" ", "_") + |> String.to_atom() + + if MapSet.member?(existing_names, client_attrs.name) do + # Skip existing client + Map.put(acc, key, :skipped) + else + {:ok, client} = OauthClients.create_client(client_attrs) + Map.put(acc, key, client) + end + end) + end + + defp get_existing_oauth_client_names do + alias Lightning.Credentials.OauthClient + + from(c in OauthClient, select: c.name) + |> Repo.all() + |> MapSet.new() + end + + defp find_oauth_client_owner(nil) do + # Try to find a superuser first, then fall back to any user + case Repo.one(from u in User, where: u.role == :superuser, limit: 1) do + nil -> + case Repo.one(from u in User, limit: 1) do + nil -> {:error, :no_users_found} + user -> {:ok, user} + end + + user -> + {:ok, user} + end + end + + defp find_oauth_client_owner(email) when is_binary(email) do + case Accounts.get_user_by_email(email) do + nil -> {:error, :user_not_found} + user -> {:ok, user} + end + end + @spec setup_demo(nil | maybe_improper_list | map) :: %{ jobs: [...], oauth_clients: map(), diff --git a/lib/mix/tasks/setup_demo_oauth_clients.ex b/lib/mix/tasks/setup_demo_oauth_clients.ex new file mode 100644 index 0000000000..bd7db6c516 --- /dev/null +++ b/lib/mix/tasks/setup_demo_oauth_clients.ex @@ -0,0 +1,135 @@ +defmodule Mix.Tasks.Lightning.SetupDemoOauthClients do + @shortdoc "Set up demo OAuth clients for Google, Salesforce, and Microsoft" + + @moduledoc """ + Sets up demo OAuth clients for Google, Salesforce, and Microsoft services. + + This task creates global OAuth clients that can be used across all projects. + It will skip any OAuth clients that already exist (by name). + + ## Usage + + mix lightning.setup_demo_oauth_clients [OPTIONS] + + ## Options + + * `--email` - Email of the user who will own the OAuth clients. + If not provided, uses the first superuser found, or falls back to + the first user in the system. + + ## Environment Variables + + Each service reads credentials from environment variables (set in `.env`): + + - `GOOGLE_DRIVE_CLIENT_ID`, `GOOGLE_DRIVE_CLIENT_SECRET` + - `GOOGLE_SHEETS_CLIENT_ID`, `GOOGLE_SHEETS_CLIENT_SECRET` + - `GMAIL_CLIENT_ID`, `GMAIL_CLIENT_SECRET` + - `SALESFORCE_CLIENT_ID`, `SALESFORCE_CLIENT_SECRET` + - `SALESFORCE_SANDBOX_CLIENT_ID`, `SALESFORCE_SANDBOX_CLIENT_SECRET` + - `MICROSOFT_SHAREPOINT_CLIENT_ID`, `MICROSOFT_SHAREPOINT_CLIENT_SECRET` + - `MICROSOFT_OUTLOOK_CLIENT_ID`, `MICROSOFT_OUTLOOK_CLIENT_SECRET` + - `MICROSOFT_CALENDAR_CLIENT_ID`, `MICROSOFT_CALENDAR_CLIENT_SECRET` + - `MICROSOFT_ONEDRIVE_CLIENT_ID`, `MICROSOFT_ONEDRIVE_CLIENT_SECRET` + - `MICROSOFT_TEAMS_CLIENT_ID`, `MICROSOFT_TEAMS_CLIENT_SECRET` + + If environment variables are not set, placeholder values will be used. + + ## Examples + + # Use default user (first superuser or first user) + mix lightning.setup_demo_oauth_clients + + # Specify a user by email + mix lightning.setup_demo_oauth_clients --email admin@example.com + + ## Created OAuth Clients + + - **Google**: Google Drive, Google Sheets, Gmail + - **Salesforce**: Salesforce (production), Salesforce Sandbox + - **Microsoft**: SharePoint, Outlook, Calendar, OneDrive, Teams + """ + + use Mix.Task + + @impl Mix.Task + def run(args) do + {opts, _, invalid} = + OptionParser.parse(args, + strict: [email: :string], + aliases: [e: :email] + ) + + if length(invalid) > 0 do + invalid_opts = Enum.map_join(invalid, ", ", fn {opt, _} -> opt end) + + Mix.raise(""" + Unknown option(s): #{invalid_opts} + + Valid options: + --email EMAIL Email of the user who will own the OAuth clients + + Run `mix help lightning.setup_demo_oauth_clients` for more information. + """) + end + + Mix.Task.run("app.start") + + setup_opts = if opts[:email], do: [user_email: opts[:email]], else: [] + + case Lightning.SetupUtils.setup_demo_oauth_clients(setup_opts) do + {:ok, oauth_clients} -> + print_results(oauth_clients) + + {:error, :no_users_found} -> + Mix.raise(""" + No users found in the database. + + Please create at least one user before running this task: + mix run -e 'Lightning.SetupUtils.setup_demo()' + """) + + {:error, :user_not_found} -> + Mix.raise(""" + User not found with email: #{opts[:email]} + + Please check the email address and try again. + """) + end + end + + defp print_results(oauth_clients) do + {created, skipped} = + Enum.split_with(oauth_clients, fn {_key, value} -> value != :skipped end) + + if length(created) > 0 do + Mix.shell().info("\nCreated OAuth clients:") + + Enum.each(created, fn {key, client} -> + Mix.shell().info(" ✓ #{client.name} (#{key})") + end) + end + + if length(skipped) > 0 do + Mix.shell().info("\nSkipped (already exist):") + + Enum.each(skipped, fn {key, _} -> + name = + key + |> Atom.to_string() + |> String.replace("_", " ") + |> String.split() + |> Enum.map(&String.capitalize/1) + |> Enum.join(" ") + + Mix.shell().info(" - #{name}") + end) + end + + total_created = length(created) + total_skipped = length(skipped) + + Mix.shell().info( + "\nDone! Created #{total_created} OAuth client(s), skipped #{total_skipped}." + ) + end +end