diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index 176e998..047ef50 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -32,8 +32,6 @@ jobs: restore-keys: ${{ runner.os }}-mix- - name: Install dependencies run: mix deps.get - - name: Check format - run: mix format --check-formatted - name: Check linter run: mix credo --strict -a - name: Run tests diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..c759752 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,101 @@ +name: Continuous Integration + +on: + push: + branches: + - master + - release/** + + pull_request: + +env: + MIX_ENV: test + +jobs: + test: + name: Test (Elixir ${{ matrix.elixir }}, OTP ${{ matrix.otp }}) + + runs-on: ubuntu-20.04 + strategy: + matrix: + # https://hexdocs.pm/elixir/compatibility-and-deprecations.html#compatibility-between-elixir-and-erlang-otp + include: + # Newest supported Elixir/Erlang pair. + - elixir: '1.15' + otp: '26.0' + lint: true + dialyzer: true + + # One version before the last supported one. + - elixir: '1.14.5' + otp: '25.3' + + steps: + - name: Check out this repository + uses: actions/checkout@v3 + + - name: Setup Elixir and Erlang + uses: erlef/setup-beam@v1 + with: + elixir-version: ${{ matrix.elixir }} + otp-version: ${{ matrix.otp }} + + # We need to manually restore and then save, so that we can save the "_build" directory + # *without* the Elixir compiled code in it. + - name: Restore Mix dependencies cache + uses: actions/cache/restore@v3 + id: mix-deps-cache + with: + path: | + _build + deps + key: | + ${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-mix-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} + restore-keys: | + ${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-mix- + + - name: Install and compile Mix dependencies + if: steps.mix-deps-cache.outputs.cache-hit != 'true' + run: mix do deps.get, deps.compile + + - name: Save Mix dependencies cache + uses: actions/cache/save@v3 + if: steps.mix-deps-cache.outputs.cache-hit != 'true' + with: + path: | + _build + deps + key: | + ${{ steps.mix-deps-cache.outputs.cache-primary-key }} + + - name: Check formatting + if: matrix.lint + run: mix format --check-formatted + + - name: Check compiler warnings + if: matrix.lint + run: mix compile --warnings-as-errors + + - name: Run tests + run: mix test + + - name: Retrieve PLT Cache + uses: actions/cache@v3 + if: matrix.dialyzer + id: plt-cache + with: + path: plts + key: | + ${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-plts-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} + restore-keys: | + ${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-plts- + + - name: Create PLTs + if: steps.plt-cache.outputs.cache-hit != 'true' && matrix.dialyzer + run: | + mkdir -p plts + mix dialyzer --plt + + - name: Run dialyzer + if: matrix.dialyzer + run: mix dialyzer --no-check --halt-exit-status diff --git a/.gitignore b/.gitignore index dfb8fbe..df89762 100755 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,9 @@ erl_crash.dump # Ignore package tarball (built via "mix hex.build"). workos-*.tar - # Temporary files for e.g. tests /tmp + +# Dialyzer +/plts/*.plt +/plts/*.plt.hash diff --git a/README.md b/README.md index 1b012cd..114670b 100755 --- a/README.md +++ b/README.md @@ -14,45 +14,25 @@ Add this package to the list of dependencies in your `mix.exs` file: ```ex def deps do - [{:workos, "~> 0.4.0"}] + [{:workos, "~> 1.0.0"}] end ``` -The hex package can be found here: https://hex.pm/packages/workos ## Configuration -The WorkOS API relies on two configuration parameters, the `client_id` and the `api_key`. There are two ways to configure these values with this package. - -### Recommended Method -In your `config/config.exs` file you can set the `:client_id` and `:api_key` scoped to `:workos` to be used globally by default across the SDK: +### Configure WorkOS API key & client ID on your app config ```ex -config :workos, - client_id: "project_12345" - api_key: "sk_12345", +config :workos, WorkOS.Client, + api_key: "sk_example_123456789", + client_id: "client_123456789" ``` -Ideally, you should use environment variables to store protected keys like your `:api_key` like so: - -```ex -config :workos, - client_id: System.get_env("WORKOS_CLIENT_ID"), - api_key: System.get_env("WORKOS_API_KEY") -``` +The only required config option is `:api_key` and `:client_id`. -### Opts Method -Alternatively, you can override or avoid using these globally configured variables by passing a `:api_key` or `:client_id` directly to SDK methods via the optional `opts` parameter available on all methods: +By default, this library uses [Tesla](https://github.com/elixir-tesla/tesla) but it can be replaced via the `:client` option, according to the `WorkOS.Client` module behavior. -```ex -WorkOS.SSO.get_authorization_url(%{ - connection: "", - redirect_uri: "https://workos.com" -}, [ - client_id: "project_12345", - api_key: "sk_12345" -]) -``` -This is great if you need to switch client IDs on the fly. +### ## SDK Versioning @@ -60,7 +40,9 @@ For our SDKs WorkOS follows a Semantic Versioning process where all releases wil ## More Information -* [Single Sign-On Guide](https://workos.com/docs/sso/guide) -* [Directory Sync Guide](https://workos.com/docs/directory-sync/guide) -* [Admin Portal Guide](https://workos.com/docs/admin-portal/guide) -* [Magic Link Guide](https://workos.com/docs/magic-link/guide) +- [User Management Guide](https://workos.com/docs/user-management) +- [Single Sign-On Guide](https://workos.com/docs/sso/guide) +- [Directory Sync Guide](https://workos.com/docs/directory-sync/guide) +- [Admin Portal Guide](https://workos.com/docs/admin-portal/guide) +- [Magic Link Guide](https://workos.com/docs/magic-link/guide) +- [Domain Verification Guide](https://workos.com/docs/domain-verification/guide) diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..871a3d1 --- /dev/null +++ b/config/config.exs @@ -0,0 +1,3 @@ +import Config + +import_config "#{Mix.env()}.exs" diff --git a/config/dev.exs b/config/dev.exs new file mode 100644 index 0000000..32741e7 --- /dev/null +++ b/config/dev.exs @@ -0,0 +1,5 @@ +import Config + +config :workos, WorkOS.Client, + client_id: System.get_env("WORKOS_CLIENT_ID"), + api_key: System.get_env("WORKOS_API_KEY") diff --git a/config/prod.exs b/config/prod.exs new file mode 100644 index 0000000..d932aaf --- /dev/null +++ b/config/prod.exs @@ -0,0 +1 @@ +# This empty module is for configuration purposes diff --git a/config/test.exs b/config/test.exs new file mode 100644 index 0000000..fb9ed47 --- /dev/null +++ b/config/test.exs @@ -0,0 +1,16 @@ +import Config + +workos_api_key = System.get_env("WORKOS_API_KEY") +workos_client_id = System.get_env("WORKOS_CLIENT_ID") + +case {workos_api_key, workos_client_id} do + {nil, nil} -> + config :tesla, adapter: Tesla.Mock + + config :workos, WorkOS.Client, + api_key: "sk_example_123456789", + client_id: "client_123456789" + + {api_key, client_id} -> + config :workos, WorkOS.Client, api_key: api_key, client_id: client_id +end diff --git a/lib/workos.ex b/lib/workos.ex index a8e90d7..e620e23 100755 --- a/lib/workos.ex +++ b/lib/workos.ex @@ -1,17 +1,123 @@ defmodule WorkOS do @moduledoc """ - Use the WorkOS module to authenticate your requests to the WorkOS API + Documentation for `WorkOS`. """ - def host, do: Application.get_env(:workos, :host) - def base_url, do: "https://" <> Application.get_env(:workos, :host) - def adapter, do: Application.get_env(:workos, :adapter) || Tesla.Adapter.Hackney + @config_module WorkOS.Client - def api_key(opts \\ []) - def api_key(api_key: api_key), do: api_key - def api_key(_opts), do: Application.get_env(:workos, :api_key) + @type config() :: + list( + {:api_key, String.t()} + | {:client_id, String.t()} + | {:base_url, String.t()} + | {:client, atom()} + ) - def client_id(opts \\ []) - def client_id(client_id: client_id), do: client_id - def client_id(_opts), do: Application.get_env(:workos, :client_id) + @doc """ + Returns a WorkOS client. + + Accepts a keyword list of config opts, though if omitted then it will attempt to load + them from the application environment. + """ + @spec client() :: WorkOS.Client.t() + @spec client(config()) :: WorkOS.Client.t() + def client(config \\ config()) do + WorkOS.Client.new(config) + end + + @doc """ + Loads config values from the application environment. + + Config options are as follows: + + ```ex + config :workos, WorkOS.Client + api_key: "sk_123", + client_id: "project_123", + base_url: "https://api.workos.com", + client: WorkOs.Client.TeslaClient + ``` + + The only required config option is `:api_key` and `:client_id`. If you would like to replace the + HTTP client used by WorkOS, configure the `:client` option. By default, this library + uses [Tesla](https://github.com/elixir-tesla/tesla), but changing it is as easy as + defining your own client module. See the `WorkOS.Client` module docs for more info. + """ + @spec config() :: config() + def config do + config = + Application.get_env(:workos, @config_module) || + raise """ + Missing client configuration for WorkOS. + + Configure your WorkOS API key in one of your config files, for example: + + config :workos, #{inspect(@config_module)}, api_key: "sk_123", client_id: "project_123" + """ + + validate_config!(config) + end + + @spec validate_config!(WorkOS.config()) :: WorkOS.config() | no_return() + defp validate_config!(config) do + Keyword.get(config, :api_key) || + raise WorkOS.ApiKeyMissingError + + Keyword.get(config, :client_id) || + raise WorkOS.ClientIdMissingError + + config + end + + @doc """ + Defines the WorkOS base API URL + """ + def default_base_url, do: "https://api.workos.com" + + @doc """ + Retrieves the WorkOS base URL from application config. + """ + @spec base_url() :: String.t() + def base_url do + case Application.get_env(:workos, @config_module) do + config when is_list(config) -> + Keyword.get(config, :base_url, default_base_url()) + + _ -> + default_base_url() + end + end + + @doc """ + Retrieves the WorkOS client ID from application config. + """ + @spec client_id() :: String.t() + def client_id do + case Application.get_env(:workos, @config_module) do + config when is_list(config) -> + Keyword.get(config, :client_id, nil) + + _ -> + nil + end + end + + @spec client_id(WorkOS.Client.t()) :: String.t() + def client_id(client) do + Map.get(client, :client_id) + end + + @doc """ + Retrieves the WorkOS API key from application config. + """ + @spec api_key() :: String.t() + def api_key do + WorkOS.config() + |> Keyword.get(:api_key) + end + + @spec api_key(WorkOS.Client.t()) :: String.t() + def api_key(client) do + Map.get(client, :api_key) + end end diff --git a/lib/workos/api.ex b/lib/workos/api.ex deleted file mode 100644 index 21f8af8..0000000 --- a/lib/workos/api.ex +++ /dev/null @@ -1,97 +0,0 @@ -defmodule WorkOS.API do - @moduledoc """ - Provides core API communication and data processing functionality. - """ - - @doc """ - Generates the Tesla client used to make requests to WorkOS - """ - def client(opts \\ []) do - auth = opts |> Keyword.get(:access_token, WorkOS.api_key(opts)) - - middleware = [ - {Tesla.Middleware.BaseUrl, WorkOS.base_url()}, - Tesla.Middleware.JSON, - {Tesla.Middleware.Headers, - [ - {"Authorization", "Bearer " <> auth} - ]} - ] - - Tesla.client(middleware, WorkOS.adapter()) - end - - @doc """ - Performs a GET request - """ - def get(path, query \\ [], opts \\ []) do - client(opts) - |> Tesla.get(path, query: query) - |> handle_response - end - - @doc """ - Performs a POST request - """ - def post(path, params \\ "", opts \\ []) do - client(opts) - |> Tesla.post(path, params) - |> handle_response - end - - @doc """ - Performs a DELETE request - """ - def delete(path, params \\ "", opts \\ []) do - client(opts) - |> Tesla.delete(path, query: params) - |> handle_response - end - - @doc """ - Performs a PUT request - """ - def put(path, params \\ "", opts \\ []) do - client(opts) - |> Tesla.put(path, params) - |> handle_response - end - - @doc """ - Processes the HTTP response - Converts non-200 responses (400+ status code) into error tuples - """ - def handle_response({:ok, %{status: status} = response}) when status >= 400, - do: handle_error({:error, response}) - - def handle_response({:ok, response}) do - {:ok, process_response(response)} - end - - def handle_response({:error, response}), do: handle_error({:error, response}) - - @doc """ - Handles request errors - """ - def handle_error({_type, response}) do - {:error, process_response(response)} - end - - @doc """ - Performs data transformations on the response body to remove JSON fluff - """ - def process_response(%{body: %{"data" => data, "listMetadata" => metadata}}) do - %{data: data, metadata: metadata} - end - - def process_response(%{body: %{"data" => data}}), do: data - def process_response(%{body: %{"message" => message}}), do: message - def process_response(%{body: body}), do: body - def process_response(message), do: message - - def process_params(params, keys, defaults \\ %{}) do - defaults - |> Map.merge(params) - |> Map.take(keys ++ Map.keys(defaults)) - end -end diff --git a/lib/workos/audit_logs.ex b/lib/workos/audit_logs.ex new file mode 100644 index 0000000..8e4f0d3 --- /dev/null +++ b/lib/workos/audit_logs.ex @@ -0,0 +1,81 @@ +defmodule WorkOS.AuditLogs do + @moduledoc """ + Manage Audit Logs in WorkOS. + + @see https://workos.com/docs/reference/audit-logs + """ + + alias WorkOS.AuditLogs.Export + alias WorkOS.Empty + + @doc """ + Creates an Audit Log Event. + + Parameter options: + + * `:organization_id` - The unique ID of the Organization. (required) + * `:event` - The Audit Log Event to be created. (required) + * `:idempotency_key` - A unique string as the value. Each subsequent request matching this unique string will return the same response. + + """ + @spec create_event(map()) :: WorkOS.Client.response(Empty.t()) + @spec create_event(WorkOS.Client.t(), map()) :: + WorkOS.Client.response(Empty.t()) + def create_event(client \\ WorkOS.client(), opts) + when is_map_key(opts, :organization_id) and is_map_key(opts, :event) do + WorkOS.Client.post( + client, + Empty, + "/audit_logs/events", + %{ + organization_id: opts[:organization_id], + event: opts[:event] + }, + headers: [ + {"Idempotency-Key", opts[:idempotency_key]} + ] + ) + end + + @doc """ + Creates an Audit Log Export. + + Parameter options: + + * `:organization_id` - The unique ID of the Organization. (required) + * `:range_start` - ISO-8601 value for start of the export range. (required) + * `:range_end` - ISO-8601 value for end of the export range. (required) + * `:actions` - List of actions to filter against. + * `:actors` - List of actor names to filter against. + * `:targets` - List of target types to filter against. + + """ + @spec create_export(map()) :: WorkOS.Client.response(Export.t()) + @spec create_export(WorkOS.Client.t(), map()) :: + WorkOS.Client.response(Export.t()) + def create_export(client \\ WorkOS.client(), opts) + when is_map_key(opts, :organization_id) and is_map_key(opts, :range_start) and + is_map_key(opts, :range_end) do + WorkOS.Client.post(client, Export, "/audit_logs/exports", %{ + organization_id: opts[:organization_id], + range_start: opts[:range_start], + range_end: opts[:range_end], + actions: opts[:actions], + actors: opts[:actors], + targets: opts[:targets] + }) + end + + @doc """ + Gets an Audit Log Export given an ID. + """ + @spec get_export(String.t()) :: WorkOS.Client.response(Export.t()) + @spec get_export(WorkOS.Client.t(), String.t()) :: WorkOS.Client.response(Export.t()) + def get_export(client \\ WorkOS.client(), audit_log_export_id) do + WorkOS.Client.get(client, Export, "/audit_logs/exports/:id", + opts: [ + path_params: [id: audit_log_export_id] + ] + ) + end +end diff --git a/lib/workos/audit_logs/audit_logs.ex b/lib/workos/audit_logs/audit_logs.ex deleted file mode 100644 index 86f4d47..0000000 --- a/lib/workos/audit_logs/audit_logs.ex +++ /dev/null @@ -1,179 +0,0 @@ -defmodule WorkOS.AuditLogs do - import WorkOS.API - - @moduledoc """ - The Audit Logs module provides convenience methods for working with the - WorkOS Audit Logs platform. You'll need a valid API key. - - See https://workos.com/docs/audit-logs - """ - - @doc """ - Create an Audit Log Event. - - ### Parameters - - params (map) - - organization (string) The unique ID of the Organization - that the event is associated with. - - event (map) The Audit Log event - - ### Examples - - iex> WorkOS.AuditLogs.create_event(%{ - ...> organization: "org_123", - ...> event: %{ - ...> action: "user.signed_in", - ...> occurred_at: "2022-09-08T19:46:03.435Z", - ...> version: 1, - ...> actor: %{ - ...> id: "user_TF4C5938", - ...> type: "user", - ...> name: "Jon Smith", - ...> metadata: %{ - ...> role: "admin" - ...> } - ...> }, - ...> targets: [ - ...> %{ - ...> id: "user_98432YHF", - ...> type: "user", - ...> name: "Jon Smith" - ...> }, - ...> %{ - ...> id: "team_J8YASKA2", - ...> type: "team", - ...> metadata: %{ - ...> owner: "user_01GBTCQ2" - ...> } - ...> } - ...> ], - ...> context: %{ - ...> location: "New York, NY", - ...> user_agent: "Chrome/104.0.0" - ...> }, - ...> metadata: %{ - ...> extra: "data" - ...> } - ...> } - ...> }) - - {:ok, nil} - - """ - - def create_event(params, opts \\ []) - - def create_event(params, opts) - when is_map_key(params, :organization) and is_map_key(params, :event) do - body = - process_params(params, [:event, :organization_id], %{ - organization_id: params.organization - }) - - post( - "/audit_logs/events", - body, - opts - ) - end - - def create_event(_params, _opts), - do: raise(ArgumentError, message: "Missing required parameters: organization, event") - - @doc """ - Create an Export of Audit Log Events. - - ### Parameters - - params (map) - - organization (string) The unique ID of the Organization - that the event is associated with. - - range_start (string) ISO-8601 value for start of the export range. - - range_end (string) ISO-8601 value for end of the export range. - - actions (list of strings) List of actions to filter against. - - actors (list of strings) List of actors to filter against. - - targets (list of strings) List of targets to filter against. - - ### Examples - - iex> WorkOS.AuditLogs.create_export(%{ - ...> organization: "org_123", - ...> range_start: "2022-09-08T19:46:03.435Z", - ...> range_end: "2022-09-08T19:46:03.435Z", - ...> actions: ["user.signed_in"], - ...> actors: ["user_01GBTCQ2"], - ...> targets: ["user_01GBTCQ2"] - ...> }) - - ok: %{ - "object": "audit_log_export", - "id": "audit_log_export_01GBZK5MP7TD1YCFQHFR22180V", - "state": "ready", - "url": "https://exports.audit-logs.com/audit-log-exports/export.csv", - "created_at": "2022-09-02T17:14:57.094Z", - "updated_at": "2022-09-02T17:14:57.094Z" - } - """ - - def create_export(params, opts \\ []) - - def create_export(params, opts) - when is_map_key(params, :organization) and - is_map_key(params, :range_start) and - is_map_key(params, :range_end) do - body = - process_params( - params, - [ - :range_start, - :range_end, - :actions, - :actors, - :targets - ], - %{ - organization_id: params.organization - } - ) - - post( - "/audit_logs/exports", - body, - opts - ) - end - - def create_export(_params, _opts), - do: - raise(ArgumentError, - message: "Missing required parameters: organization, range_start, range_end" - ) - - @doc """ - Retrieve an Export of Audit Log Events. - - ### Parameters - - id (string) The unique ID of the Audit Log Export. - - ### Examples - - iex> WorkOS.AuditLogs.get_export("audit_log_export_01GBZK5MP7TD1YCFQHFR22180V") - - ok: %{ - "object": "audit_log_export", - "id": "audit_log_export_01GBZK5MP7TD1YCFQHFR22180V", - "state": "ready", - "url": "https://exports.audit-logs.com/audit-log-exports/export.csv", - "created_at": "2022-09-02T17:14:57.094Z", - "updated_at": "2022-09-02T17:14:57.094Z" - } - """ - - def get_export(id, opts \\ []) - - def get_export(id, opts) when is_binary(id) do - get("/audit_logs/exports/#{id}", opts) - end - - def get_export(_id, _opts), - do: raise(ArgumentError, message: "Missing required parameters: id") -end diff --git a/lib/workos/audit_logs/export.ex b/lib/workos/audit_logs/export.ex new file mode 100644 index 0000000..48b4ff4 --- /dev/null +++ b/lib/workos/audit_logs/export.ex @@ -0,0 +1,38 @@ +defmodule WorkOS.AuditLogs.Export do + @moduledoc """ + WorkOS Audit Logs Export struct. + """ + + @behaviour WorkOS.Castable + + @type t() :: %__MODULE__{ + id: String.t(), + object: String.t(), + state: String.t(), + url: String.t() | nil, + updated_at: String.t(), + created_at: String.t() + } + + @enforce_keys [:id, :object, :state, :updated_at, :created_at] + defstruct [ + :id, + :object, + :state, + :url, + :updated_at, + :created_at + ] + + @impl true + def cast(map) do + %__MODULE__{ + id: map["id"], + object: map["object"], + state: map["state"], + url: map["url"], + updated_at: map["updated_at"], + created_at: map["created_at"] + } + end +end diff --git a/lib/workos/castable.ex b/lib/workos/castable.ex new file mode 100644 index 0000000..05c2c7d --- /dev/null +++ b/lib/workos/castable.ex @@ -0,0 +1,36 @@ +defmodule WorkOS.Castable do + @moduledoc false + + @type impl :: module() | {module(), module()} | :raw + @type generic_map :: %{String.t() => any()} + + @callback cast(generic_map() | {module(), generic_map()} | nil) :: struct() | nil + + @spec cast(impl(), generic_map() | nil) :: struct() | generic_map() | nil + def cast(_implementation, nil) do + nil + end + + def cast(:raw, generic_map) do + generic_map + end + + def cast({implementation, inner}, generic_map) when is_map(generic_map) do + implementation.cast({inner, generic_map}) + end + + def cast(implementation, generic_map) when is_map(generic_map) do + implementation.cast(generic_map) + end + + def cast(WorkOS.Empty, "Accepted"), do: %WorkOS.Empty{status: "Accepted"} + + @spec cast_list(module(), [generic_map()] | nil) :: [struct()] | nil + def cast_list(_implementation, nil) do + nil + end + + def cast_list(implementation, list_of_generic_maps) when is_list(list_of_generic_maps) do + Enum.map(list_of_generic_maps, &cast(implementation, &1)) + end +end diff --git a/lib/workos/client.ex b/lib/workos/client.ex new file mode 100644 index 0000000..a81511b --- /dev/null +++ b/lib/workos/client.ex @@ -0,0 +1,126 @@ +defmodule WorkOS.Client do + @moduledoc """ + WorkOS API client. + """ + + require Logger + + alias WorkOS.Castable + + @callback request(t(), Keyword.t()) :: + {:ok, %{body: map(), status: pos_integer()}} | {:error, any()} + + @type response(type) :: {:ok, type} | {:error, WorkOS.Error.t() | :client_error} + + @type t() :: %__MODULE__{ + api_key: String.t(), + client_id: String.t(), + base_url: String.t() | nil, + client: module() | nil + } + + @enforce_keys [:api_key, :client_id, :base_url, :client] + defstruct [:api_key, :client_id, :base_url, :client] + + @default_opts [ + base_url: WorkOS.default_base_url(), + client: __MODULE__.TeslaClient + ] + + @doc """ + Creates a new WorkOS client struct given a keyword list of config opts. + """ + @spec new(WorkOS.config()) :: t() + def new(config) do + config = Keyword.take(config, [:api_key, :client_id, :base_url, :client]) + struct!(__MODULE__, Keyword.merge(@default_opts, config)) + end + + @spec get(t(), Castable.impl(), String.t()) :: response(any()) + @spec get(t(), Castable.impl(), String.t(), Keyword.t()) :: response(any()) + def get(client, castable_module, path, opts \\ []) do + client_module = client.client || WorkOS.Client.TeslaClient + + query = Keyword.get(opts, :query, %{}) + filtered_query = Enum.reject(query, fn {_, value} -> value == nil end) + + opts = + opts + |> Keyword.put(:method, :get) + |> Keyword.put(:url, path) + |> Keyword.put(:query, filtered_query) + + client_module.request(client, opts) + |> handle_response(path, castable_module) + end + + @spec post(t(), Castable.impl(), String.t()) :: response(any()) + @spec post(t(), Castable.impl(), String.t(), map()) :: response(any()) + @spec post(t(), Castable.impl(), String.t(), map(), Keyword.t()) :: response(any()) + def post(client, castable_module, path, body \\ %{}, opts \\ []) do + client_module = client.client || WorkOS.Client.TeslaClient + + opts = + opts + |> Keyword.put(:method, :post) + |> Keyword.put(:url, path) + |> Keyword.put(:body, body) + + client_module.request(client, opts) + |> handle_response(path, castable_module) + end + + @spec put(t(), Castable.impl(), String.t()) :: response(any()) + @spec put(t(), Castable.impl(), String.t(), map()) :: response(any()) + @spec put(t(), Castable.impl(), String.t(), map(), Keyword.t()) :: response(any()) + def put(client, castable_module, path, body \\ %{}, opts \\ []) do + client_module = client.client || WorkOS.Client.TeslaClient + + opts = + opts + |> Keyword.put(:method, :put) + |> Keyword.put(:url, path) + |> Keyword.put(:body, body) + + client_module.request(client, opts) + |> handle_response(path, castable_module) + end + + @spec delete(t(), Castable.impl(), String.t()) :: response(any()) + @spec delete(t(), Castable.impl(), String.t(), map()) :: response(any()) + @spec delete(t(), Castable.impl(), String.t(), map(), Keyword.t()) :: response(any()) + def delete(client, castable_module, path, body \\ %{}, opts \\ []) do + client_module = client.client || WorkOS.Client.TeslaClient + + opts = + opts + |> Keyword.put(:method, :delete) + |> Keyword.put(:url, path) + |> Keyword.put(:body, body) + + client_module.request(client, opts) + |> handle_response(path, castable_module) + end + + defp handle_response(response, path, castable_module) do + case response do + {:ok, %{body: "", status: status}} when status in 200..299 -> + {:ok, Castable.cast(castable_module, %{})} + + {:ok, %{body: body, status: status}} when status in 200..299 -> + {:ok, Castable.cast(castable_module, body)} + + {:ok, %{body: body}} when is_map(body) -> + Logger.error("#{inspect(__MODULE__)} error when calling #{path}: #{inspect(body)}") + {:error, Castable.cast(WorkOS.Error, body)} + + {:ok, %{body: body}} when is_binary(body) -> + Logger.error("#{inspect(__MODULE__)} error when calling #{path}: #{body}") + {:error, body} + + {:error, reason} -> + Logger.error("#{inspect(__MODULE__)} error when calling #{path}: #{inspect(reason)}") + {:error, :client_error} + end + end +end diff --git a/lib/workos/client/tesla_client.ex b/lib/workos/client/tesla_client.ex new file mode 100644 index 0000000..54f2d42 --- /dev/null +++ b/lib/workos/client/tesla_client.ex @@ -0,0 +1,31 @@ +defmodule WorkOS.Client.TeslaClient do + @moduledoc """ + Tesla client for WorkOS. This is the default HTTP client used. + """ + + @behaviour WorkOS.Client + + @doc """ + Sends a request to a WorkOS API endpoint, given list of request opts. + """ + def request(client, opts) do + opts = Keyword.take(opts, [:method, :url, :query, :headers, :body, :opts]) + access_token = opts |> Keyword.get(:opts, []) |> Keyword.get(:access_token, client.api_key) + + Tesla.request(new(client, access_token), opts) + end + + @doc """ + Returns a new `Tesla.Client`, configured for calling the WorkOS API. + """ + @spec new(WorkOS.Client.t(), String.t()) :: Tesla.Client.t() + def new(client, access_token) do + Tesla.client([ + Tesla.Middleware.Logger, + {Tesla.Middleware.BaseUrl, client.base_url}, + Tesla.Middleware.PathParams, + Tesla.Middleware.JSON, + {Tesla.Middleware.Headers, [{"Authorization", "Bearer #{access_token}"}]} + ]) + end +end diff --git a/lib/workos/directory_sync.ex b/lib/workos/directory_sync.ex new file mode 100644 index 0000000..691b49b --- /dev/null +++ b/lib/workos/directory_sync.ex @@ -0,0 +1,193 @@ +defmodule WorkOS.DirectorySync do + @moduledoc """ + Manage Directory Sync in WorkOS. + + @see https://workos.com/docs/reference/directory-sync + """ + + alias WorkOS.DirectorySync.Directory + alias WorkOS.DirectorySync.Directory.Group + alias WorkOS.DirectorySync.Directory.User + alias WorkOS.Empty + + @doc """ + Gets a directory given an ID. + """ + @spec get_directory(String.t()) :: WorkOS.Client.response(Directory.t()) + @spec get_directory(WorkOS.Client.t(), String.t()) :: + WorkOS.Client.response(Directory.t()) + def get_directory(client \\ WorkOS.client(), directory_id) do + WorkOS.Client.get(client, Directory, "/directories/:id", + opts: [ + path_params: [id: directory_id] + ] + ) + end + + @doc """ + Lists all directories. + + Parameter options: + + * `:domain` - The domain of a Directory. + * `:organization_id` - Filter Directories by their associated organization. + * `:search` - Searchable text to match against Directory names. + * `:limit` - Maximum number of records to return. Accepts values between 1 and 100. Default is 10. + * `:before` - An object ID that defines your place in the list. When the ID is not present, you are at the end of the list. + * `:after` - Pagination cursor to receive records after a provided event ID. + * `:order` - Order the results by the creation time. Supported values are "asc" and "desc" for showing older and newer records first respectively. + + """ + @spec list_directories(WorkOS.Client.t(), map()) :: + WorkOS.Client.response(WorkOS.List.t(Directory.t())) + def list_directories(client, opts) do + WorkOS.Client.get(client, WorkOS.List.of(Directory), "/directories", + query: [ + domain: opts[:domain], + organization_id: opts[:organization_id], + search: opts[:search], + limit: opts[:limit], + before: opts[:before], + after: opts[:after], + order: opts[:order] + ] + ) + end + + @spec list_directories(map()) :: + WorkOS.Client.response(WorkOS.List.t(Directory.t())) + def list_directories(opts \\ %{}) do + WorkOS.Client.get(WorkOS.client(), WorkOS.List.of(Directory), "/directories", + query: [ + domain: opts[:domain], + organization_id: opts[:organization_id], + search: opts[:search], + limit: opts[:limit], + before: opts[:before], + after: opts[:after], + order: opts[:order] + ] + ) + end + + @doc """ + Deletes a directory. + """ + @spec delete_directory(String.t()) :: WorkOS.Client.response(nil) + @spec delete_directory(WorkOS.Client.t(), String.t()) :: WorkOS.Client.response(nil) + def delete_directory(client \\ WorkOS.client(), directory_id) do + WorkOS.Client.delete(client, Empty, "/directories/:id", %{}, + opts: [ + path_params: [id: directory_id] + ] + ) + end + + @doc """ + Gets a directory user given an ID. + """ + def get_user(client \\ WorkOS.client(), directory_user_id) do + WorkOS.Client.get(client, User, "/directory_users/:id", + opts: [ + path_params: [id: directory_user_id] + ] + ) + end + + @doc """ + Lists all directory users. + + Parameter options: + + * `:directory` - Unique identifier of the WorkOS Directory. + * `:group` - Unique identifier of the WorkOS Directory Group. + * `:limit` - Maximum number of records to return. Accepts values between 1 and 100. Default is 10. + * `:before` - An object ID that defines your place in the list. When the ID is not present, you are at the end of the list. + * `:after` - Pagination cursor to receive records after a provided event ID. + * `:order` - Order the results by the creation time. Supported values are "asc" and "desc" for showing older and newer records first respectively. + + """ + @spec list_users(WorkOS.Client.t(), map()) :: + WorkOS.Client.response(WorkOS.List.t(User.t())) + def list_users(client, opts) do + WorkOS.Client.get(client, WorkOS.List.of(User), "/directory_users", + query: [ + directory: opts[:directory], + group: opts[:group], + limit: opts[:limit], + before: opts[:before], + after: opts[:after], + order: opts[:order] + ] + ) + end + + @spec list_users(map()) :: + WorkOS.Client.response(WorkOS.List.t(User.t())) + def list_users(opts \\ %{}) do + WorkOS.Client.get(WorkOS.client(), WorkOS.List.of(User), "/directory_users", + query: [ + directory: opts[:directory], + group: opts[:group], + limit: opts[:limit], + before: opts[:before], + after: opts[:after], + order: opts[:order] + ] + ) + end + + @doc """ + Gets a directory group given an ID. + """ + def get_group(client \\ WorkOS.client(), directory_group_id) do + WorkOS.Client.get(client, Group, "/directory_groups/:id", + opts: [ + path_params: [id: directory_group_id] + ] + ) + end + + @doc """ + Lists all directory groups. + + Parameter options: + + * `:directory` - Unique identifier of the WorkOS Directory. + * `:user` - Unique identifier of the WorkOS Directory User. + * `:limit` - Maximum number of records to return. Accepts values between 1 and 100. Default is 10. + * `:before` - An object ID that defines your place in the list. When the ID is not present, you are at the end of the list. + * `:after` - Pagination cursor to receive records after a provided event ID. + * `:order` - Order the results by the creation time. Supported values are "asc" and "desc" for showing older and newer records first respectively. + + """ + @spec list_groups(WorkOS.Client.t(), map()) :: + WorkOS.Client.response(WorkOS.List.t(Group.t())) + def list_groups(client, opts) do + WorkOS.Client.get(client, WorkOS.List.of(Group), "/directory_groups", + query: [ + directory: opts[:directory], + user: opts[:user], + limit: opts[:limit], + before: opts[:before], + after: opts[:after], + order: opts[:order] + ] + ) + end + + @spec list_groups(map()) :: + WorkOS.Client.response(WorkOS.List.t(Group.t())) + def list_groups(opts \\ %{}) do + WorkOS.Client.get(WorkOS.client(), WorkOS.List.of(Group), "/directory_groups", + query: [ + directory: opts[:directory], + user: opts[:user], + limit: opts[:limit], + before: opts[:before], + after: opts[:after], + order: opts[:order] + ] + ) + end +end diff --git a/lib/workos/directory_sync/directory.ex b/lib/workos/directory_sync/directory.ex new file mode 100644 index 0000000..5ebed1e --- /dev/null +++ b/lib/workos/directory_sync/directory.ex @@ -0,0 +1,60 @@ +defmodule WorkOS.DirectorySync.Directory do + @moduledoc """ + WorkOS Directory struct. + """ + + @behaviour WorkOS.Castable + + @type t() :: %__MODULE__{ + id: String.t(), + object: String.t(), + external_key: String.t(), + state: String.t(), + updated_at: String.t(), + created_at: String.t(), + name: String.t(), + domain: String.t(), + organization_id: String.t() | nil, + type: String.t() + } + + @enforce_keys [ + :id, + :object, + :external_key, + :state, + :updated_at, + :created_at, + :name, + :domain, + :type + ] + defstruct [ + :id, + :object, + :external_key, + :state, + :updated_at, + :created_at, + :name, + :domain, + :organization_id, + :type + ] + + @impl true + def cast(map) do + %__MODULE__{ + id: map["id"], + object: map["object"], + type: map["type"], + external_key: map["external_key"], + state: map["state"], + name: map["name"], + organization_id: map["organization_id"], + domain: map["domain"], + updated_at: map["updated_at"], + created_at: map["created_at"] + } + end +end diff --git a/lib/workos/directory_sync/directory/group.ex b/lib/workos/directory_sync/directory/group.ex new file mode 100644 index 0000000..1cb2e90 --- /dev/null +++ b/lib/workos/directory_sync/directory/group.ex @@ -0,0 +1,56 @@ +defmodule WorkOS.DirectorySync.Directory.Group do + @moduledoc """ + WorkOS Directory Group struct. + """ + + @behaviour WorkOS.Castable + + @type t() :: %__MODULE__{ + id: String.t(), + name: String.t(), + object: String.t(), + idp_id: String.t(), + directory_id: String.t(), + organization_id: String.t() | nil, + raw_attributes: %{String.t() => any()}, + updated_at: String.t(), + created_at: String.t() + } + + @enforce_keys [ + :id, + :name, + :object, + :idp_id, + :directory_id, + :raw_attributes, + :updated_at, + :created_at + ] + defstruct [ + :id, + :name, + :object, + :idp_id, + :directory_id, + :updated_at, + :created_at, + :raw_attributes, + :organization_id + ] + + @impl true + def cast(map) do + %__MODULE__{ + id: map["id"], + name: map["name"], + object: map["object"], + idp_id: map["idp_id"], + directory_id: map["directory_id"], + organization_id: map["organization_id"], + raw_attributes: map["raw_attributes"], + updated_at: map["updated_at"], + created_at: map["created_at"] + } + end +end diff --git a/lib/workos/directory_sync/directory/user.ex b/lib/workos/directory_sync/directory/user.ex new file mode 100644 index 0000000..425e559 --- /dev/null +++ b/lib/workos/directory_sync/directory/user.ex @@ -0,0 +1,79 @@ +defmodule WorkOS.DirectorySync.Directory.User do + @moduledoc """ + WorkOS Directory User struct. + """ + + @behaviour WorkOS.Castable + + @type t() :: %__MODULE__{ + id: String.t(), + object: String.t(), + directory_id: String.t(), + organization_id: String.t() | nil, + raw_attributes: %{String.t() => any()}, + custom_attributes: %{String.t() => any()}, + idp_id: String.t(), + first_name: String.t(), + emails: [%{type: String.t(), value: String.t(), primary: boolean()}], + username: String.t(), + last_name: String.t(), + job_title: String.t() | nil, + state: String.t(), + updated_at: String.t(), + created_at: String.t() + } + + @enforce_keys [ + :id, + :object, + :directory_id, + :raw_attributes, + :custom_attributes, + :idp_id, + :first_name, + :emails, + :username, + :last_name, + :state, + :updated_at, + :created_at + ] + defstruct [ + :id, + :object, + :directory_id, + :organization_id, + :raw_attributes, + :custom_attributes, + :idp_id, + :first_name, + :emails, + :username, + :last_name, + :job_title, + :state, + :updated_at, + :created_at + ] + + @impl true + def cast(map) do + %__MODULE__{ + id: map["id"], + object: map["object"], + organization_id: map["organization_id"], + raw_attributes: map["raw_attributes"], + custom_attributes: map["custom_attributes"], + directory_id: map["directory_id"], + idp_id: map["idp_id"], + first_name: map["first_name"], + emails: map["emails"], + username: map["username"], + last_name: map["last_name"], + job_title: map["job_title"], + state: map["state"], + updated_at: map["updated_at"], + created_at: map["created_at"] + } + end +end diff --git a/lib/workos/directory_sync/directory_sync.ex b/lib/workos/directory_sync/directory_sync.ex deleted file mode 100644 index b9667cb..0000000 --- a/lib/workos/directory_sync/directory_sync.ex +++ /dev/null @@ -1,126 +0,0 @@ -defmodule WorkOS.DirectorySync do - import WorkOS.API - - @moduledoc """ - The Directory Sync module provides convenience methods for working with the - WorkOS Directory Sync platform. You'll need a valid API key and to have - created a Directory Sync connection on your WorkOS dashboard. - - @see https://docs.workos.com/directory-sync/overview - """ - - @doc """ - Retrieve directory groups. - - ### Parameters - - params (map) - - directory (string) the id of the directory to list groups for - - user (string) the id of the user to list groups for - - limit (number - optional) Upper limit on the number of objects to return, between 1 and 100. The default value is 10 - - before (string - optional) An object ID that defines your place in the list - - after (string - optional) An object ID that defines your place in the list - - order ("asc" or "desc" - optional) Supported values are "asc" and "desc" for ascending and descending order respectively - - ### Example - WorkOS.DirectorySync.list_groups(%{directory: "directory_12345"}) - """ - def list_groups(params \\ %{}, opts \\ []) do - query = process_params(params, [:directory, :user, :limit, :before, :after, :order]) - get("/directory_groups", query, opts) - end - - @doc """ - Retrieve directory users. - - ### Parameters - - params (map) - - directory (string) the id of the directory to list users for - - group (string) the id of the group to list users for - - limit (number - optional) Upper limit on the number of objects to return, between 1 and 100. The default value is 10 - - before (string - optional) An object ID that defines your place in the list - - after (string - optional) An object ID that defines your place in the list - - order ("asc" or "desc" - optional) Supported values are "asc" and "desc" for ascending and descending order respectively - - ### Example - WorkOS.DirectorySync.list_users(%{directory: "directory_12345"}) - """ - def list_users(params \\ %{}, opts \\ []) do - query = process_params(params, [:directory, :group, :limit, :before, :after, :order]) - get("/directory_users", query, opts) - end - - @doc """ - Retrieve directories. - - ### Parameters - - params (map) - - domain (string) the id of the domain to list directories for - - search (string) the keyword to search directories for - - limit (number - optional) Upper limit on the number of objects to return, between 1 and 100. The default value is 10 - - before (string - optional) An object ID that defines your place in the list - - after (string - optional) An object ID that defines your place in the list - - order ("asc" or "desc" - optional) Supported values are "asc" and "desc" for ascending and descending order respectively - - organization_id (string) the id of the organization to list directories for - - ### Example - WorkOS.DirectorySync.list_directories(%{domain: "workos.com"}) - """ - def list_directories(params \\ %{}, opts \\ []) do - query = - process_params(params, [:domain, :search, :limit, :before, :after, :order, :organization_id]) - - get("/directories", query, opts) - end - - @doc """ - Retrieve the directory group with the given ID. - - ### Parameters - - user (string) the id of the user to retrieve - - ### Example - WorkOS.DirectorySync.get_user("directory_user_12345") - """ - def get_user(user, opts \\ []) do - get("/directory_users/#{user}", %{}, opts) - end - - @doc """ - Retrieve the directory group with the given ID. - - ### Parameters - - group (string) the id of the group to retrieve - - ### Example - WorkOS.DirectorySync.get_group("directory_group_12345") - """ - def get_group(group, opts \\ []) do - get("/directory_groups/#{group}", %{}, opts) - end - - @doc """ - Retrieve the directory with the given ID. - - ### Parameters - - directory (string) the id of the directory to retrieve - - ### Example - WorkOS.DirectorySync.get_directory("directory_12345") - """ - def get_directory(directory, opts \\ []) do - get("/directories/#{directory}", %{}, opts) - end - - @doc """ - Delete the directory with the given ID. - - ### Parameters - - directory (string) the id of the directory to delete - - ### Example - WorkOS.DirectorySync.delete_directory("directory_12345") - """ - def delete_directory(directory, opts \\ []) do - delete("/directories/#{directory}", %{}, opts) - end -end diff --git a/lib/workos/empty.ex b/lib/workos/empty.ex new file mode 100644 index 0000000..6450d1f --- /dev/null +++ b/lib/workos/empty.ex @@ -0,0 +1,15 @@ +defmodule WorkOS.Empty do + @moduledoc """ + Empty response. + """ + + @behaviour WorkOS.Castable + + @type t() :: %__MODULE__{status: String.t()} | :accepted + + defstruct [:status] + + @impl true + def cast(_map), do: %__MODULE__{} + def cast(__MODULE__, "Accepted"), do: %__MODULE__{status: "Accepted"} +end diff --git a/lib/workos/error.ex b/lib/workos/error.ex new file mode 100644 index 0000000..c6aad65 --- /dev/null +++ b/lib/workos/error.ex @@ -0,0 +1,39 @@ +defmodule WorkOS.Error do + @moduledoc """ + Castable module for returning structured errors from the WorkOS API. + """ + + @behaviour WorkOS.Castable + + @type unprocessable_entity_error() :: %{ + field: String.t(), + code: String.t() + } + + @type t() :: %__MODULE__{ + code: String.t() | nil, + error: String.t() | nil, + errors: [unprocessable_entity_error()] | nil, + message: String.t(), + error_description: String.t() | nil + } + + defstruct [ + :code, + :error, + :errors, + :message, + :error_description + ] + + @impl true + def cast(error) when is_map(error) do + %__MODULE__{ + code: error["code"], + error: error["error"], + errors: error["errors"], + message: error["message"], + error_description: error["error_description"] + } + end +end diff --git a/lib/workos/errors/api_key_missing.ex b/lib/workos/errors/api_key_missing.ex new file mode 100644 index 0000000..92e6b6d --- /dev/null +++ b/lib/workos/errors/api_key_missing.ex @@ -0,0 +1,11 @@ +defmodule WorkOS.ApiKeyMissingError do + @moduledoc """ + Exception for when a request is made without an API key. + """ + + defexception message: """ + The api_key setting is required to make requests to WorkOS. + Please configure :api_key in config.exs, set the WORKOS_API_KEY + environment variable, or pass into a new client instance. + """ +end diff --git a/lib/workos/errors/client_id_missing.ex b/lib/workos/errors/client_id_missing.ex new file mode 100644 index 0000000..4d7e397 --- /dev/null +++ b/lib/workos/errors/client_id_missing.ex @@ -0,0 +1,11 @@ +defmodule WorkOS.ClientIdMissingError do + @moduledoc """ + Exception for when a request is made without a client ID. + """ + + defexception message: """ + The client_id setting is required to make requests to WorkOS. + Please configure :client_id in config.exs, set the WORKOS_CLIENT_ID + environment variable, or pass into a new client instance. + """ +end diff --git a/lib/workos/events.ex b/lib/workos/events.ex new file mode 100644 index 0000000..d4d38f6 --- /dev/null +++ b/lib/workos/events.ex @@ -0,0 +1,49 @@ +defmodule WorkOS.Events do + @moduledoc """ + Manage Events API in WorkOS. + + @see https://workos.com/docs/events + """ + + alias WorkOS.Events.Event + + @doc """ + Lists all events. + + Parameter options: + + * `:events` - Filter to only return events of particular types. + * `:range_start` - Date range start for stream of events. + * `:range_end` - Date range end for stream of events. + * `:limit` - Maximum number of records to return. Accepts values between 1 and 100. Default is 10. + * `:after` - Pagination cursor to receive records after a provided event ID. + + """ + @spec list_events(WorkOS.Client.t(), map()) :: + WorkOS.Client.response(WorkOS.List.t(Event.t())) + def list_events(client, opts) do + WorkOS.Client.get(client, WorkOS.List.of(Event), "/events", + query: [ + events: opts[:events], + range_start: opts[:range_start], + range_end: opts[:range_end], + limit: opts[:limit], + after: opts[:after] + ] + ) + end + + @spec list_events(map()) :: + WorkOS.Client.response(WorkOS.List.t(Event.t())) + def list_events(opts \\ %{}) do + WorkOS.Client.get(WorkOS.client(), WorkOS.List.of(Event), "/events", + query: [ + events: opts[:events], + range_start: opts[:range_start], + range_end: opts[:range_end], + limit: opts[:limit], + after: opts[:after] + ] + ) + end +end diff --git a/lib/workos/events/event.ex b/lib/workos/events/event.ex new file mode 100644 index 0000000..b800d22 --- /dev/null +++ b/lib/workos/events/event.ex @@ -0,0 +1,32 @@ +defmodule WorkOS.Events.Event do + @moduledoc """ + WorkOS Event struct. + """ + + @behaviour WorkOS.Castable + + @type t() :: %__MODULE__{ + id: String.t(), + event: String.t(), + data: map(), + created_at: String.t() + } + + @enforce_keys [:id, :event, :created_at] + defstruct [ + :id, + :event, + :data, + :created_at + ] + + @impl true + def cast(map) do + %__MODULE__{ + id: map["id"], + data: map["data"], + event: map["event"], + created_at: map["created_at"] + } + end +end diff --git a/lib/workos/list.ex b/lib/workos/list.ex new file mode 100644 index 0000000..16b1667 --- /dev/null +++ b/lib/workos/list.ex @@ -0,0 +1,29 @@ +defmodule WorkOS.List do + @moduledoc """ + Casts a response to a `%WorkOS.List{}` of structs. + """ + + alias WorkOS.Castable + + @behaviour WorkOS.Castable + + @type t(g) :: %__MODULE__{ + data: list(g), + list_metadata: map() + } + + @enforce_keys [:data, :list_metadata] + defstruct [:data, :list_metadata] + + @impl true + def cast({implementation, map}) do + %__MODULE__{ + data: Castable.cast_list(implementation, map["data"]), + list_metadata: map["list_metadata"] + } + end + + def of(implementation) do + {__MODULE__, implementation} + end +end diff --git a/lib/workos/mfa.ex b/lib/workos/mfa.ex new file mode 100644 index 0000000..8432109 --- /dev/null +++ b/lib/workos/mfa.ex @@ -0,0 +1,124 @@ +defmodule WorkOS.MFA do + @moduledoc """ + This module is deprecated. + """ + + @deprecated "MFA has been replaced by the User Management Multi-Factor API." + + alias WorkOS.Empty + alias WorkOS.MFA.AuthenticationChallenge + alias WorkOS.MFA.AuthenticationFactor + alias WorkOS.MFA.VerifyChallenge + + @doc """ + Enrolls an Authentication Factor. + + Parameter options: + + * `:type` - The type of the factor to enroll. Only option available is `totp`. (required) + * `:totp_issuer` - For `totp` factors. Typically your application or company name, this helps users distinguish between factors in authenticator apps. + * `:totp_user` - For `totp` factors. Used as the account name in authenticator apps. Defaults to the user's email. + * `:phone_number` - A valid phone number for an SMS-enabled device. Required when type is sms. + + """ + @spec enroll_factor(map()) :: WorkOS.Client.response(AuthenticationFactor.t()) + @spec enroll_factor(WorkOS.Client.t(), map()) :: + WorkOS.Client.response(AuthenticationFactor.t()) + def enroll_factor(client \\ WorkOS.client(), opts) when is_map_key(opts, :type) do + WorkOS.Client.post( + client, + AuthenticationFactor, + "/auth/factors/enroll", + %{ + type: opts[:type], + totp_issuer: opts[:totp_issuer], + totp_user: opts[:totp_user], + phone_number: opts[:phone_number] + } + ) + end + + @doc """ + Creates a Challenge for an Authentication Factor. + + Parameter options: + + * `:authentication_factor_id` - The ID of the Authentication Factor. (required) + * `:sms_template` - A valid phone number for an SMS-enabled device. Required when type is sms. + + """ + @spec challenge_factor(map()) :: WorkOS.Client.response(AuthenticationChallenge.t()) + @spec challenge_factor(WorkOS.Client.t(), map()) :: + WorkOS.Client.response(AuthenticationChallenge.t()) + def challenge_factor(client \\ WorkOS.client(), opts) + when is_map_key(opts, :authentication_factor_id) do + WorkOS.Client.post( + client, + AuthenticationChallenge, + "/auth/factors/:id/challenge", + %{ + sms_template: opts[:sms_template] + }, + opts: [ + path_params: [id: opts[:authentication_factor_id]] + ] + ) + end + + @doc """ + Verifies Authentication Challenge. + + Parameter options: + + * `:code` - The 6 digit code to be verified. (required) + + """ + @spec verify_challenge(String.t(), map()) :: WorkOS.Client.response(VerifyChallenge.t()) + @spec verify_challenge(WorkOS.Client.t(), String.t(), map()) :: + WorkOS.Client.response(VerifyChallenge.t()) + def verify_challenge(client \\ WorkOS.client(), authentication_challenge_id, opts) + when is_map_key(opts, :code) do + WorkOS.Client.post( + client, + VerifyChallenge, + "/auth/challenges/:id/verify", + %{ + code: opts[:code] + }, + opts: [ + path_params: [id: authentication_challenge_id] + ] + ) + end + + @doc """ + Gets an Authentication Factor. + """ + @spec get_factor(String.t()) :: + WorkOS.Client.response(AuthenticationFactor.t()) + @spec get_factor(WorkOS.Client.t(), String.t()) :: + WorkOS.Client.response(AuthenticationFactor.t()) + def get_factor(client \\ WorkOS.client(), authentication_factor_id) do + WorkOS.Client.get( + client, + AuthenticationFactor, + "/auth/factors/:id", + opts: [ + path_params: [id: authentication_factor_id] + ] + ) + end + + @doc """ + Deletes an Authentication Factor. + """ + @spec delete_factor(String.t()) :: WorkOS.Client.response(nil) + @spec delete_factor(WorkOS.Client.t(), String.t()) :: WorkOS.Client.response(nil) + def delete_factor(client \\ WorkOS.client(), authentication_factor_id) do + WorkOS.Client.delete(client, Empty, "/auth/factors/:id", %{}, + opts: [ + path_params: [id: authentication_factor_id] + ] + ) + end +end diff --git a/lib/workos/mfa/authentication_challenge.ex b/lib/workos/mfa/authentication_challenge.ex new file mode 100644 index 0000000..a5e3215 --- /dev/null +++ b/lib/workos/mfa/authentication_challenge.ex @@ -0,0 +1,35 @@ +defmodule WorkOS.MFA.AuthenticationChallenge do + @moduledoc """ + This response struct is deprecated. Use the User Management Multi-Factor API instead. + """ + + @behaviour WorkOS.Castable + + @type t() :: %__MODULE__{ + id: String.t(), + authentication_factor_id: String.t(), + expires_at: String.t(), + updated_at: String.t(), + created_at: String.t() + } + + @enforce_keys [:id, :authentication_factor_id, :expires_at, :updated_at, :created_at] + defstruct [ + :id, + :authentication_factor_id, + :expires_at, + :updated_at, + :created_at + ] + + @impl true + def cast(map) do + %__MODULE__{ + id: map["id"], + authentication_factor_id: map["authentication_factor_id"], + expires_at: map["expires_at"], + updated_at: map["updated_at"], + created_at: map["created_at"] + } + end +end diff --git a/lib/workos/mfa/authentication_factor.ex b/lib/workos/mfa/authentication_factor.ex new file mode 100644 index 0000000..5963883 --- /dev/null +++ b/lib/workos/mfa/authentication_factor.ex @@ -0,0 +1,41 @@ +defmodule WorkOS.MFA.AuthenticationFactor do + @moduledoc """ + This response struct is deprecated. Use the User Management Multi-Factor API instead. + """ + + @behaviour WorkOS.Castable + + alias WorkOS.MFA.SMS + alias WorkOS.MFA.TOTP + + @type t() :: %__MODULE__{ + id: String.t(), + type: String.t(), + sms: SMS.t() | nil, + totp: TOTP.t() | nil, + updated_at: String.t(), + created_at: String.t() + } + + @enforce_keys [:id, :type, :sms, :totp, :updated_at, :created_at] + defstruct [ + :id, + :type, + :sms, + :totp, + :updated_at, + :created_at + ] + + @impl true + def cast(map) do + %__MODULE__{ + id: map["id"], + type: map["type"], + sms: map["sms"], + totp: map["totp"], + updated_at: map["updated_at"], + created_at: map["created_at"] + } + end +end diff --git a/lib/workos/mfa/mfa.ex b/lib/workos/mfa/mfa.ex deleted file mode 100644 index 2a986ac..0000000 --- a/lib/workos/mfa/mfa.ex +++ /dev/null @@ -1,305 +0,0 @@ -defmodule WorkOS.MFA do - import WorkOS.API - - @factor_types ["totp", "sms"] - - @moduledoc """ - The MFA module provides convenience methods for working with the - WorkOS MFA platform. You'll need a valid API key. - - See https://workos.com/docs/mfa - """ - - @doc """ - Enrolls an Authentication Factor to be used as an additional factor of authentication. The returned ID should be used to create an authentication Challenge. - - ### Parameters - - params (map) - - type (string) The type of factor you wish to enroll. Must be one of 'totp' or 'sms'. - - totp_issuer (string) An identifier for the organization issuing the challenge. - Should be the name of your application or company. Required when type is totp. - - totp_user (string) An identifier for the user. Used by authenticator apps to label connections. Required when type is totp. - - phone_number (string) A valid phone number for an SMS-enabled device. Required when type is sms. - - ### Examples - - iex> WorkOS.MFA.enroll_factor(%{ - ...> type: "totp", - ...> totp_issuer: "Foo Corp", - ...> totp_user: "user_01GBTCQ2" - ...> }) - - {:ok, %{ - object: "authentication_factor", - id: "auth_factor_01FVYZ5QM8N98T9ME5BCB2BBMJ", - created_at: "2022-02-15T15:14:19.392Z", - updated_at: "2022-02-15T15:14:19.392Z", - type: "totp", - totp: %{ - qr_code: "data:image/png;base64,{base64EncodedPng}", - secret: "NAGCCFS3EYRB422HNAKAKY3XDUORMSRF", - "uri": "otpauth://totp/FooCorp:user_01GB" - } - }} - - iex> WorkOS.MFA.enroll_factor(%{ - ...> type: "sms", - ...> phone_number: "+15555555555" - ...> }) - - {:ok, %{ - object: "authentication_factor", - id: "auth_factor_01FVYZ5QM8N98T9ME5BCB2BBMJ", - created_at: "2022-02-15T15:14:19.392Z", - updated_at: "2022-02-15T15:14:19.392Z", - type: "sms", - sms: %{ - phone_number: "+15555555555" - } - }} - - """ - - def enroll_factor(params, opts \\ []) - - def enroll_factor(%{type: type}, _opts) - when type not in @factor_types do - raise(ArgumentError, "#{type} is not a valid value. Type must be one of #{@factor_types}") - end - - def enroll_factor(%{type: type, phone_number: phone_number} = params, opts) - when type in @factor_types and - type == "sms" and - is_binary(phone_number) do - body = - process_params( - params, - [ - :phone_number - ], - %{ - type: type - } - ) - - post("/auth/factors/enroll", body, opts) - end - - def enroll_factor( - %{ - type: type, - totp_issuer: totp_issuer, - totp_user: totp_user - } = params, - opts - ) - when type in @factor_types and - type == "totp" and - is_binary(totp_issuer) and - is_binary(totp_user) do - body = - process_params( - params, - [ - :totp_issuer, - :totp_user - ], - %{ - type: type - } - ) - - post("/auth/factors/enroll", body, opts) - end - - def enroll_factor(_params, _opts) do - raise( - ArgumentError, - "Invalid parameters for enroll_factor. Must include type, totp_issuer, and totp_user for type 'totp', or type and phone_number for type 'sms'." - ) - end - - @doc """ - Creates a Challenge for an Authentication Factor. - - ### Parameters - - params (map) - - authentication_factor_id (string) The unique ID of the Authentication Factor to be challenged. - - sms_template (string) Optional template for SMS messages. Only applicable for sms Factors. - Use the {{code}} token to inject the one-time code into the message. E.g., Your Foo Corp one-time code is {{code}}. - - ### Examples - - iex> WorkOS.MFA.challenge_factor(%{ - ...> authentication_factor_id: "auth_factor_01FVYZ5QM8N98T9ME5BCB2BBMJ", - ...> }) - - {:ok, %{ - "object": "authentication_challenge", - "id": "auth_challenge_01FVYZWQTZQ5VB6BC5MPG2EYC5", - "created_at": "2022-02-15T15:26:53.274Z", - "updated_at": "2022-02-15T15:26:53.274Z", - "expires_at": "2022-02-15T15:36:53.279Z", - "authentication_factor_id": "auth_factor_01FVYZ5QM8N98T9ME5BCB2BBMJ" - }} - - iex> WorkOS.MFA.challenge_factor(%{ - ...> authentication_factor_id: "auth_factor_01FVYZ5QM8N98T9ME5BCB2BBMJ", - ...> sms_template: "Your Foo Corp one-time code is {{code}}" - ...> }) - - {:ok, %{ - "object": "authentication_challenge", - "id": "auth_challenge_01FVYZWQTZQ5VB6BC5MPG2EYC5", - "created_at": "2022-02-15T15:26:53.274Z", - "updated_at": "2022-02-15T15:26:53.274Z", - "expires_at": "2022-02-15T15:36:53.279Z", - "authentication_factor_id": "auth_factor_01FVYZ5QM8N98T9ME5BCB2BBMJ" - }} - - """ - def challenge_factor(params, opts \\ []) - - def challenge_factor( - %{ - authentication_factor_id: authentication_factor_id - } = params, - opts - ) - when is_map(params) and - is_binary(authentication_factor_id) do - body = - process_params( - params, - [ - :authentication_factor_id, - :sms_template - ] - ) - - post("/auth/factors/#{params[:authentication_factor_id]}/challenge", body, opts) - end - - def challenge_factor(_params, _opts) do - raise( - ArgumentError, - "Invalid parameters for challenge_factor. Must include authentication_factor_id." - ) - end - - @doc """ - Verify Authentication Challenge. - - ### Parameters - - params (map) - - authentication_challenge_id (string) The unique ID of the Authentication Challenge to be verified. - - code (string) The 6-digit code to be verified. - - ### Examples - - iex > WorkOS.MFA.verify_challenge(%{ - ... > authentication_challenge_id: "auth_challenge_01FVYZWQTZQ5VB6BC5MPG2EYC5", - ... > code: "123456" - ... > }) - - {:ok, %{ - "challenge": %{ - "object": "authentication_challenge", - "id": "auth_challenge_01FVYZWQTZQ5VB6BC5MPG2EYC5", - "created_at": "2022-02-15T15:26:53.274Z", - "updated_at": "2022-02-15T15:26:53.274Z", - "expires_at": "2022-02-15T15:36:53.279Z", - "authentication_factor_id": "auth_factor_01FVYZ5QM8N98T9ME5BCB2BBMJ", - }, - "valid": true - }} - """ - def verify_challenge(params, opts \\ []) - - def verify_challenge( - %{ - authentication_challenge_id: authentication_challenge_id, - code: code - } = params, - opts - ) - when is_map(params) and - is_binary(authentication_challenge_id) and - is_binary(code) do - body = - process_params( - params, - [ - :authentication_challenge_id, - :code - ] - ) - - post("/auth/challenges/#{params[:authentication_challenge_id]}/verify", body, opts) - end - - def verify_challenge(_params, _opts) do - raise( - ArgumentError, - "Invalid parameters for verify_challenge. Must include authentication_challenge_id and code." - ) - end - - @doc """ - Gets an Authentication Factor. - - ### Parameters - - id (string) The unique ID of the Authentication Factor to retrieve. - - ### Examples - iex > WorkOS.MFA.get_factor("auth_factor_01FVYZ5QM8N98T9ME5BCB2BBMJ") - - {:ok, %{ - "object": "authentication_factor", - "id": "auth_factor_01FVYZ5QM8N98T9ME5BCB2BBMJ", - "created_at": "2022-02-15T15:14:19.392Z", - "updated_at": "2022-02-15T15:14:19.392Z", - "type": "totp", - "totp": { - "qr_code": "data:image/png;base64,{base64EncodedPng}", - "secret": "NAGCCFS3EYRB422HNAKAKY3XDUORMSRF", - "uri": "otpauth://totp/FooCorp:alan.turing@foo-corp.com?secret=NAGCCFS3EYRB422HNAKAKY3XDUORMSRF&issuer=FooCorp" - } - } - """ - def get_factor(id, opts \\ []) - - def get_factor(id, opts) when is_binary(id) do - get("/auth/factors/#{id}", opts) - end - - def get_factor(_id, _opts) do - raise( - ArgumentError, - "Invalid parameters for get_factor. Must include id." - ) - end - - @doc """ - Deletes an Authentication Factor. - - ### Parameters - - id (string) The unique ID of the Authentication Factor to delete. - - ### Examples - - iex > WorkOS.MFA.delete_factor("auth_factor_01FVYZ5QM8N98T9ME5BCB2BBMJ") - """ - def delete_factor(id, opts \\ []) - - def delete_factor(id, opts) when is_binary(id) do - delete("/auth/factors/#{id}", opts) - end - - def delete_factor(_id, _opts) do - raise( - ArgumentError, - "Invalid parameters for delete_factor. Must include id." - ) - end -end diff --git a/lib/workos/mfa/sms.ex b/lib/workos/mfa/sms.ex new file mode 100644 index 0000000..967001c --- /dev/null +++ b/lib/workos/mfa/sms.ex @@ -0,0 +1,25 @@ +defmodule WorkOS.MFA.SMS do + @moduledoc """ + This response struct is deprecated. Use the User Management Multi-Factor API instead. + """ + + @behaviour WorkOS.Castable + + @type t() :: %__MODULE__{ + phone_number: String.t() + } + + @enforce_keys [ + :phone_number + ] + defstruct [ + :phone_number + ] + + @impl true + def cast(map) do + %__MODULE__{ + phone_number: map["phone_number"] + } + end +end diff --git a/lib/workos/mfa/totp.ex b/lib/workos/mfa/totp.ex new file mode 100644 index 0000000..4f33a60 --- /dev/null +++ b/lib/workos/mfa/totp.ex @@ -0,0 +1,41 @@ +defmodule WorkOS.MFA.TOTP do + @moduledoc """ + This response struct is deprecated. Use the User Management Multi-Factor API instead. + """ + + @behaviour WorkOS.Castable + + @type t() :: %__MODULE__{ + issuer: String.t(), + user: String.t(), + secret: String.t(), + qr_code: String.t(), + uri: String.t() + } + + @enforce_keys [ + :issuer, + :user, + :secret, + :qr_code, + :uri + ] + defstruct [ + :issuer, + :user, + :secret, + :qr_code, + :uri + ] + + @impl true + def cast(map) do + %__MODULE__{ + issuer: map["issuer"], + user: map["user"], + secret: map["secret"], + qr_code: map["qr_code"], + uri: map["uri"] + } + end +end diff --git a/lib/workos/mfa/verify_challenge.ex b/lib/workos/mfa/verify_challenge.ex new file mode 100644 index 0000000..8a74df7 --- /dev/null +++ b/lib/workos/mfa/verify_challenge.ex @@ -0,0 +1,28 @@ +defmodule WorkOS.MFA.VerifyChallenge do + @moduledoc """ + This response struct is deprecated. Use the User Management Multi-Factor API instead. + """ + + @behaviour WorkOS.Castable + + alias WorkOS.MFA.AuthenticationChallenge + + @type t() :: %__MODULE__{ + challenge: AuthenticationChallenge.t(), + valid: boolean() + } + + @enforce_keys [:challenge, :valid] + defstruct [ + :challenge, + :valid + ] + + @impl true + def cast(map) do + %__MODULE__{ + challenge: map["challenge"], + valid: map["valid"] + } + end +end diff --git a/lib/workos/organization_domains.ex b/lib/workos/organization_domains.ex new file mode 100644 index 0000000..a053ed5 --- /dev/null +++ b/lib/workos/organization_domains.ex @@ -0,0 +1,62 @@ +defmodule WorkOS.OrganizationDomains do + @moduledoc """ + Manage Organization Domains in WorkOS. + + @see https://workos.com/docs/reference/domain-verification + """ + + alias WorkOS.OrganizationDomains.OrganizationDomain + + @doc """ + Gets an organization domain given an ID. + """ + @spec get_organization_domain(String.t()) :: WorkOS.Client.response(OrganizationDomain.t()) + @spec get_organization_domain(WorkOS.Client.t(), String.t()) :: + WorkOS.Client.response(OrganizationDomain.t()) + def get_organization_domain(client \\ WorkOS.client(), organization_domain_id) do + WorkOS.Client.get(client, OrganizationDomain, "/organization_domains/:id", + opts: [ + path_params: [id: organization_domain_id] + ] + ) + end + + @doc """ + Creates an organization domain. + + Parameter options: + + * `:organization_id` - ID of the parent Organization. (required) + * `:domain` - Domain for the Organization Domain. (required) + + """ + @spec create_organization_domain(map()) :: WorkOS.Client.response(OrganizationDomain.t()) + @spec create_organization_domain(WorkOS.Client.t(), map()) :: + WorkOS.Client.response(OrganizationDomain.t()) + def create_organization_domain(client \\ WorkOS.client(), opts) + when is_map_key(opts, :organization_id) and is_map_key(opts, :domain) do + WorkOS.Client.post( + client, + OrganizationDomain, + "/organization_domains", + %{ + organization_id: opts[:organization_id], + domain: opts[:domain] + } + ) + end + + @doc """ + Verifies an organization domain + """ + @spec verify_organization_domain(String.t()) :: WorkOS.Client.response(OrganizationDomain.t()) + @spec verify_organization_domain(WorkOS.Client.t(), String.t()) :: + WorkOS.Client.response(OrganizationDomain.t()) + def verify_organization_domain(client \\ WorkOS.client(), organization_domain_id) do + WorkOS.Client.post(client, OrganizationDomain, "/organization_domains/:id/verify", %{}, + opts: [ + path_params: [id: organization_domain_id] + ] + ) + end +end diff --git a/lib/workos/organization_domains/organization_domain.ex b/lib/workos/organization_domains/organization_domain.ex new file mode 100644 index 0000000..18860fa --- /dev/null +++ b/lib/workos/organization_domains/organization_domain.ex @@ -0,0 +1,45 @@ +defmodule WorkOS.OrganizationDomains.OrganizationDomain do + @moduledoc """ + WorkOS Organization Domain struct. + """ + + @behaviour WorkOS.Castable + + @type t() :: %__MODULE__{ + id: String.t(), + organization_id: String.t(), + domain: String.t(), + state: String.t(), + verification_strategy: String.t(), + verification_token: String.t() + } + + @enforce_keys [ + :id, + :organization_id, + :domain, + :state, + :verification_strategy, + :verification_token + ] + defstruct [ + :id, + :organization_id, + :domain, + :state, + :verification_strategy, + :verification_token + ] + + @impl true + def cast(map) do + %__MODULE__{ + id: map["id"], + organization_id: map["organization_id"], + domain: map["domain"], + state: map["state"], + verification_strategy: map["verification_strategy"], + verification_token: map["verification_token"] + } + end +end diff --git a/lib/workos/organizations.ex b/lib/workos/organizations.ex new file mode 100644 index 0000000..a3b58c3 --- /dev/null +++ b/lib/workos/organizations.ex @@ -0,0 +1,130 @@ +defmodule WorkOS.Organizations do + @moduledoc """ + Manage Organizations in WorkOS. + + @see https://workos.com/docs/reference/organization + """ + + alias WorkOS.Empty + alias WorkOS.Organizations.Organization + + @doc """ + Lists all organizations. + + Parameter options: + + * `:domains` - The domains of an Organization. Any Organization with a matching domain will be returned. + * `:limit` - Maximum number of records to return. Accepts values between 1 and 100. Default is 10. + * `:after` - Pagination cursor to receive records after a provided event ID. + * `:before` - An object ID that defines your place in the list. When the ID is not present, you are at the end of the list. + * `:order` - Order the results by the creation time. Supported values are "asc" and "desc" for showing older and newer records first respectively. + + """ + @spec list_organizations(WorkOS.Client.t(), map()) :: + WorkOS.Client.response(WorkOS.List.t(Organization.t())) + def list_organizations(client, opts) do + WorkOS.Client.get(client, WorkOS.List.of(Organization), "/organizations", + query: [ + domains: opts[:domains], + limit: opts[:limit], + after: opts[:after], + before: opts[:before], + order: opts[:order] + ] + ) + end + + @spec list_organizations(map()) :: + WorkOS.Client.response(WorkOS.List.t(Organization.t())) + def list_organizations(opts \\ %{}) do + WorkOS.Client.get(WorkOS.client(), WorkOS.List.of(Organization), "/organizations", + query: [ + domains: opts[:domains], + limit: opts[:limit], + after: opts[:after], + before: opts[:before], + order: opts[:order] + ] + ) + end + + @doc """ + Deletes an organization. + """ + @spec delete_organization(String.t()) :: WorkOS.Client.response(nil) + @spec delete_organization(WorkOS.Client.t(), String.t()) :: WorkOS.Client.response(nil) + def delete_organization(client \\ WorkOS.client(), organization_id) do + WorkOS.Client.delete(client, Empty, "/organizations/:id", %{}, + opts: [ + path_params: [id: organization_id] + ] + ) + end + + @doc """ + Gets an organization given an ID. + """ + @spec get_organization(String.t()) :: WorkOS.Client.response(Organization.t()) + @spec get_organization(WorkOS.Client.t(), String.t()) :: + WorkOS.Client.response(Organization.t()) + def get_organization(client \\ WorkOS.client(), organization_id) do + WorkOS.Client.get(client, Organization, "/organizations/:id", + opts: [ + path_params: [id: organization_id] + ] + ) + end + + @doc """ + Creates an organization. + + Parameter options: + + * `:name` - A descriptive name for the Organization. This field does not need to be unique. (required) + * `:domains` - The domains of the Organization. + * `:allow_profiles_outside_organization` - Whether the Connections within this Organization should allow Profiles that do not have a domain that is present in the set of the Organization’s User Email Domains. + * `:idempotency_key` - A unique string as the value. Each subsequent request matching this unique string will return the same response. + + """ + @spec create_organization(map()) :: WorkOS.Client.response(Organization.t()) + @spec create_organization(WorkOS.Client.t(), map()) :: + WorkOS.Client.response(Organization.t()) + def create_organization(client \\ WorkOS.client(), opts) when is_map_key(opts, :name) do + WorkOS.Client.post( + client, + Organization, + "/organizations", + %{ + name: opts[:name], + domains: opts[:domains], + allow_profiles_outside_organization: opts[:allow_profiles_outside_organization] + }, + headers: [ + {"Idempotency-Key", opts[:idempotency_key]} + ] + ) + end + + @doc """ + Updates an organization. + + Parameter options: + + * `:organization` - Unique identifier of the Organization. (required) + * `:name` - A descriptive name for the Organization. This field does not need to be unique. (required) + * `:domains` - The domains of the Organization. + * `:allow_profiles_outside_organization` - Whether the Connections within this Organization should allow Profiles that do not have a domain that is present in the set of the Organization’s User Email Domains. + + """ + @spec update_organization(String.t(), map()) :: WorkOS.Client.response(Organization.t()) + @spec update_organization(WorkOS.Client.t(), String.t(), map()) :: + WorkOS.Client.response(Organization.t()) + def update_organization(client \\ WorkOS.client(), organization_id, opts) + when is_map_key(opts, :name) do + WorkOS.Client.put(client, Organization, "/organizations/#{organization_id}", %{ + name: opts[:name], + domains: opts[:domains], + allow_profiles_outside_organization: !!opts[:allow_profiles_outside_organization] + }) + end +end diff --git a/lib/workos/organizations/organization.ex b/lib/workos/organizations/organization.ex new file mode 100644 index 0000000..1747392 --- /dev/null +++ b/lib/workos/organizations/organization.ex @@ -0,0 +1,52 @@ +defmodule WorkOS.Organizations.Organization do + @moduledoc """ + WorkOS Organization struct. + """ + + alias WorkOS.Castable + alias WorkOS.Organizations.Organization.Domain + + @behaviour WorkOS.Castable + + @type t() :: %__MODULE__{ + id: String.t(), + object: String.t(), + name: String.t(), + allow_profiles_outside_organization: boolean(), + domains: list(Domain.t()) | nil, + updated_at: String.t(), + created_at: String.t() + } + + @enforce_keys [ + :id, + :object, + :name, + :allow_profiles_outside_organization, + :domains, + :updated_at, + :created_at + ] + defstruct [ + :id, + :object, + :name, + :allow_profiles_outside_organization, + :domains, + :updated_at, + :created_at + ] + + @impl true + def cast(map) do + %__MODULE__{ + id: map["id"], + object: map["object"], + name: map["name"], + domains: Castable.cast_list(Domain, map["domains"]), + allow_profiles_outside_organization: map["allow_profiles_outside_organization"], + updated_at: map["updated_at"], + created_at: map["created_at"] + } + end +end diff --git a/lib/workos/organizations/organization/domain.ex b/lib/workos/organizations/organization/domain.ex new file mode 100644 index 0000000..bf52c3a --- /dev/null +++ b/lib/workos/organizations/organization/domain.ex @@ -0,0 +1,29 @@ +defmodule WorkOS.Organizations.Organization.Domain do + @moduledoc """ + WorkOS Organization Domain struct. + """ + + @behaviour WorkOS.Castable + + @type t() :: %__MODULE__{ + id: String.t(), + object: String.t(), + domain: String.t() + } + + @enforce_keys [:id, :object, :domain] + defstruct [ + :id, + :object, + :domain + ] + + @impl true + def cast(map) do + %__MODULE__{ + id: map["id"], + object: map["object"], + domain: map["domain"] + } + end +end diff --git a/lib/workos/organizations/organizations.ex b/lib/workos/organizations/organizations.ex deleted file mode 100644 index 7b3c36e..0000000 --- a/lib/workos/organizations/organizations.ex +++ /dev/null @@ -1,107 +0,0 @@ -defmodule WorkOS.Organizations do - import WorkOS.API - - @moduledoc """ - The Organizations module provides resource methods for working with Organizations - """ - - @doc """ - Create an organization - - ### Parameters - - params (map) - - name (string) A unique, descriptive name for the organization - - allow_profiles_outside_organization (boolean - optional) Whether the Connections within this Organization should allow Profiles that do not have a domain that is set - - domains (array of strings - optional) List of domains that belong to the organization - - ### Example - WorkOS.Organizations.create_organization(%{ - domains: ["workos.com"], - name: "WorkOS" - }) - """ - def create_organization(params, opts \\ []) - - def create_organization(params, opts) - when (is_map_key(params, :domains) or - is_map_key(params, :allow_profiles_outside_organization)) and - is_map_key(params, :name) do - body = process_params(params, [:name, :domains, :allow_profiles_outside_organization]) - post("/organizations", body, opts) - end - - def create_organization(_params, _opts), - do: - raise(ArgumentError, - message: "need both domains (unless external profiles set to true) and name in params" - ) - - @doc """ - Delete an organization - - ### Parameters - - organization_id (string) the id of the organization to delete - - ### Example - WorkOS.Organizations.delete_organization("organization_12345") - """ - def delete_organization(organization, opts \\ []) do - delete("/organizations/#{organization}", %{}, opts) - end - - @doc """ - Update an organization - - ### Parameters - - organization (string) The ID of the organization to update - - params (map) - - name (string) Name of organization - - allow_profiles_outside_organization (boolean - optional) Whether the Connections within this Organization should allow Profiles that do not have a domain that is set - - domains (array of strings - optional) List of domains that belong to the organization - - ### Example - WorkOS.Organizations.update_organization(organization="organization_12345", %{ - domains: ["workos.com"], - name: "WorkOS" - }) - """ - def update_organization(organization, params, opts \\ []) - when (is_map_key(params, :domains) or - is_map_key(params, :allow_profiles_outside_organization)) and - is_map_key(params, :name) do - body = process_params(params, [:name, :domains, :allow_profiles_outside_organization]) - put("/organizations/#{organization}", body, opts) - end - - @doc """ - Get an organization - - ### Parameters - - organization_id (string) the id of the organization to update - - ### Example - WorkOS.Organizations.get_organization(organization="org_123") - """ - def get_organization(organization, opts \\ []) do - get("/organizations/#{organization}", %{}, opts) - end - - @doc """ - List organizations - - ### Parameters - - params (map) - - domains (array of strings - optional) List of domains that belong to the organization - - limit (number - optional) Upper limit on the number of objects to return, between 1 and 100. The default value is 10 - - before (string - optional) An object ID that defines your place in the list - - after (string - optional) An object ID that defines your place in the list - - order ("asc" or "desc" - optional) Supported values are "asc" and "desc" for ascending and descending order respectively - - ### Example - WorkOS.Organizations.list_organizations() - """ - def list_organizations(params \\ %{}, opts \\ []) do - query = process_params(params, [:domains, :limit, :before, :after, :order]) - get("/organizations", query, opts) - end -end diff --git a/lib/workos/passwordless.ex b/lib/workos/passwordless.ex new file mode 100644 index 0000000..c6ec48a --- /dev/null +++ b/lib/workos/passwordless.ex @@ -0,0 +1,60 @@ +defmodule WorkOS.Passwordless do + @moduledoc """ + Manage Magic Link API in WorkOS. + + @see https://workos.com/docs/reference/magic-link + """ + + alias WorkOS.Passwordless.Session + alias WorkOS.Passwordless.Session.Send + + @doc """ + Creates a Passwordless Session for a Magic Link Connection + + Parameter options: + + * `:type` - The type of Passwordless Session to create. Currently, the only supported value is `MagicLink`. (required) + * `:email` - The email of the user to authenticate. (required) + * `:redirect_uri` - Optional parameter that a developer can choose to include in their authorization URL. + * `:expires_in` - The number of seconds the Passwordless Session should live before expiring. + * `:state` - Optional parameter that a developer can choose to include in their authorization URL. + + """ + @spec create_session(map()) :: WorkOS.Client.response(Session.t()) + @spec create_session(WorkOS.Client.t(), map()) :: + WorkOS.Client.response(Session.t()) + def create_session(client \\ WorkOS.client(), opts) + when is_map_key(opts, :email) and is_map_key(opts, :type) do + WorkOS.Client.post( + client, + Session, + "/passwordless/sessions", + %{ + email: opts[:email], + type: opts[:type], + redirect_uri: opts[:redirect_uri], + expires_in: opts[:expires_in], + state: opts[:state] + } + ) + end + + @doc """ + Emails a user the Magic Link confirmation URL, given a Passwordless session ID. + """ + @spec send_session(String.t()) :: + WorkOS.Client.response(Send) + @spec send_session(WorkOS.Client.t(), String.t()) :: + WorkOS.Client.response(Send) + def send_session(client \\ WorkOS.client(), session_id) do + WorkOS.Client.post( + client, + Send, + "/passwordless/sessions/:id/send", + %{}, + opts: [ + path_params: [id: session_id] + ] + ) + end +end diff --git a/lib/workos/passwordless/passwordless.ex b/lib/workos/passwordless/passwordless.ex deleted file mode 100644 index d1b0867..0000000 --- a/lib/workos/passwordless/passwordless.ex +++ /dev/null @@ -1,62 +0,0 @@ -defmodule WorkOS.Passwordless do - import WorkOS.API - - @moduledoc """ - The Passwordless module provides convenience methods for working with - passwordless sessions including the WorkOS Magic Link. You'll need a valid - API key. - - @see https://workos.com/docs/sso/configuring-magic-link - """ - - @doc """ - Create a Passwordless Session. - - ### Parameters - - params (map) - - email (string) The email of the user to authenticate. - - state (string) Optional parameter that the redirect URI - received from WorkOS will contain. The state parameter can be used to - encode arbitrary information to help restore application state between - redirects. - - type (string) The type of Passwordless Session to - create. Currently, the only supported value is 'MagicLink'. - - redirect_uri (string) The URI where users are directed - after completing the authentication step. Must match a - configured redirect URI on your WorkOS dashboard. - - ### Example - WorkOS.Passwordless.create_session(%{ - email: "example@workos.com", - redirect_uri: "https://workos.com/passwordless" - }) - """ - def create_session(params, opts \\ []) - - def create_session(params, opts) - when is_map_key(params, :email) or is_map_key(params, :connection) do - query = - process_params(params, [:email, :connection, :redirect_uri, :state, :type, :expires_in], %{ - type: "MagicLink" - }) - - post("/passwordless/sessions", query, opts) - end - - def create_session(_params, _opts), - do: raise(ArgumentError, message: "need email in params or connection id") - - @doc """ - Send a Passwordless Session via email. - - ### Parameters - - session_id (string) The unique identifier of the - Passwordless Session to send an email for. - - ### Example - WorkOS.Passwordless.send_session("passwordless_session_12345") - """ - def send_session(session_id, opts \\ []) do - post("/passwordless/sessions/#{session_id}/send", %{}, opts) - end -end diff --git a/lib/workos/passwordless/session.ex b/lib/workos/passwordless/session.ex new file mode 100644 index 0000000..91d0abe --- /dev/null +++ b/lib/workos/passwordless/session.ex @@ -0,0 +1,35 @@ +defmodule WorkOS.Passwordless.Session do + @moduledoc """ + WorkOS Passwordless session struct. + """ + + @behaviour WorkOS.Castable + + @type t() :: %__MODULE__{ + id: String.t(), + email: String.t(), + link: String.t(), + object: String.t(), + expires_at: String.t() + } + + @enforce_keys [:id, :email, :link, :object, :expires_at] + defstruct [ + :id, + :email, + :link, + :object, + :expires_at + ] + + @impl true + def cast(map) do + %__MODULE__{ + id: map["id"], + email: map["email"], + link: map["link"], + object: map["object"], + expires_at: map["expires_at"] + } + end +end diff --git a/lib/workos/passwordless/session/send.ex b/lib/workos/passwordless/session/send.ex new file mode 100644 index 0000000..d269e19 --- /dev/null +++ b/lib/workos/passwordless/session/send.ex @@ -0,0 +1,23 @@ +defmodule WorkOS.Passwordless.Session.Send do + @moduledoc """ + WorkOS Send Passwordless session struct. + """ + + @behaviour WorkOS.Castable + + @type t() :: %__MODULE__{ + success: boolean() + } + + @enforce_keys [:success] + defstruct [ + :success + ] + + @impl true + def cast(map) do + %__MODULE__{ + success: map["success"] + } + end +end diff --git a/lib/workos/portal.ex b/lib/workos/portal.ex new file mode 100644 index 0000000..ef855d2 --- /dev/null +++ b/lib/workos/portal.ex @@ -0,0 +1,61 @@ +defmodule WorkOS.Portal do + @moduledoc """ + Manage Portal in WorkOS. + + @see https://workos.com/docs/reference/admin-portal + """ + + alias WorkOS.Portal.Link + + @generate_portal_link_intent [ + "audit_logs", + "domain_verification", + "dsync", + "log_streams", + "sso" + ] + + @doc """ + Generates a Portal Link + + Parameter options: + + * `:organization` - An Organization identifier. (required) + * `:intent` - The intent of the Admin Portal. (required) + * `:return_url` - The URL to which WorkOS should send users when they click on the link to return to your website. + * `:success_url` - The URL to which WorkOS will redirect users to upon successfully setting up Single Sign-On or Directory Sync. + + """ + def generate_link(client \\ WorkOS.client(), _opts) + + def generate_link(_client, %{intent: intent} = _opts) + when intent not in @generate_portal_link_intent, + do: + raise(ArgumentError, + message: + "Invalid intent, must be one of the following: " <> + Enum.join(@generate_portal_link_intent, ", ") + ) + + @spec generate_link(map()) :: + WorkOS.Client.response(Link.t()) + @spec generate_link(WorkOS.Client.t(), map()) :: + WorkOS.Client.response(Link.t()) + def generate_link(client, opts) + when is_map_key(opts, :organization) and is_map_key(opts, :intent) do + WorkOS.Client.post( + client, + Link, + "/portal/generate_link", + %{ + organization: opts[:organization], + intent: opts[:intent], + return_url: opts[:return_url], + success_url: opts[:success_url] + } + ) + end + + def generate_link(_client, _opts), + do: raise(ArgumentError, message: "Needs both intent and organization.") +end diff --git a/lib/workos/portal/link.ex b/lib/workos/portal/link.ex new file mode 100644 index 0000000..df7a99e --- /dev/null +++ b/lib/workos/portal/link.ex @@ -0,0 +1,25 @@ +defmodule WorkOS.Portal.Link do + @moduledoc """ + WorkOS Portal Link struct. + """ + + @behaviour WorkOS.Castable + + @type t() :: %__MODULE__{ + link: String.t() + } + + @enforce_keys [ + :link + ] + defstruct [ + :link + ] + + @impl true + def cast(map) do + %__MODULE__{ + link: map["link"] + } + end +end diff --git a/lib/workos/portal/portal.ex b/lib/workos/portal/portal.ex deleted file mode 100644 index 6d6f4b9..0000000 --- a/lib/workos/portal/portal.ex +++ /dev/null @@ -1,52 +0,0 @@ -defmodule WorkOS.Portal do - import WorkOS.API - - @generate_link_intents ["sso", "dsync", "audit_logs", "log_streams"] - - @moduledoc """ - The Portal module provides resource methods for working with the Admin - Portal product - - @see https://workos.com/docs/admin-portal/guide - """ - - @doc """ - Generate a link to grant access to an organization's Admin Portal - - ### Parameters - - params (map) - - intent (string) The access scope for the generated Admin Portal - link. Valid values are: ["sso", "dsync", "audit_logs", "log_streams"] - - organization (string) The ID of the organization the Admin - Portal link will be generated for. - - return_url (string) The URL that the end user will be redirected to upon - exiting the generated Admin Portal. If none is provided, the default - redirect link set in your WorkOS Dashboard will be used. - - success_url (string) he URL to which WorkOS will redirect users to upon - successfully setting up Single Sign On or Directory Sync. - - ### Example - WorkOS.Portal.generate_link(%{ - intent: "sso", - organization: "org_1234" - }) - """ - def generate_link(params, opts \\ []) - - def generate_link(%{intent: intent} = _params, _opts) - when intent not in @generate_link_intents, - do: - raise(ArgumentError, - message: - "invalid intent, must be one of the following: sso, dsync, audit_logs or log_streams" - ) - - def generate_link(params, opts) - when is_map_key(params, :organization) and is_map_key(params, :intent) do - query = process_params(params, [:intent, :organization, :return_url, :success_url]) - post("/portal/generate_link", query, opts) - end - - def generate_link(_params, _opts), - do: raise(ArgumentError, message: "need both intent and organization in params") -end diff --git a/lib/workos/sso.ex b/lib/workos/sso.ex new file mode 100644 index 0000000..fc974e0 --- /dev/null +++ b/lib/workos/sso.ex @@ -0,0 +1,173 @@ +defmodule WorkOS.SSO do + @moduledoc """ + Manage Single Sign-On (SSO) in WorkOS. + + @see https://docs.workos.com/sso/overview + """ + + require Logger + + alias WorkOS.Empty + alias WorkOS.SSO.Connection + alias WorkOS.SSO.Profile + alias WorkOS.SSO.ProfileAndToken + + @doc """ + Lists all connections. + + Parameter options: + + * `:connection_type` - Filter Connections by their type. + * `:organization_id` - Filter Connections by their associated organization. + * `:domain` - Filter Connections by their associated domain. + * `:limit` - Maximum number of records to return. Accepts values between 1 and 100. Default is 10. + * `:after` - Pagination cursor to receive records after a provided event ID. + * `:before` - An object ID that defines your place in the list. When the ID is not present, you are at the end of the list. + * `:order` - Order the results by the creation time. Supported values are "asc" and "desc" for showing older and newer records first respectively. + + """ + @spec list_connections(WorkOS.Client.t(), map()) :: + WorkOS.Client.response(WorkOS.List.t(Connection.t())) + def list_connections(client, opts) do + WorkOS.Client.get(client, WorkOS.List.of(Connection), "/connections", + query: [ + connection_type: opts[:connection_type], + organization_id: opts[:organization_id], + domain: opts[:domain], + limit: opts[:limit], + after: opts[:after], + before: opts[:before], + order: opts[:order] + ] + ) + end + + @spec list_connections(map()) :: + WorkOS.Client.response(WorkOS.List.t(Connection.t())) + def list_connections(opts \\ %{}) do + WorkOS.Client.get(WorkOS.client(), WorkOS.List.of(Connection), "/connections", + query: [ + connection_type: opts[:connection_type], + organization_id: opts[:organization_id], + domain: opts[:domain], + limit: opts[:limit], + after: opts[:after], + before: opts[:before], + order: opts[:order] + ] + ) + end + + @doc """ + Deletes a connection. + """ + @spec delete_connection(String.t()) :: WorkOS.Client.response(nil) + @spec delete_connection(WorkOS.Client.t(), String.t()) :: WorkOS.Client.response(nil) + def delete_connection(client \\ WorkOS.client(), connection_id) do + WorkOS.Client.delete(client, Empty, "/connections/:id", %{}, + opts: [ + path_params: [id: connection_id] + ] + ) + end + + @doc """ + Gets a connection given an ID. + """ + @spec get_connection(String.t()) :: WorkOS.Client.response(Connection.t()) + @spec get_connection(WorkOS.Client.t(), String.t()) :: WorkOS.Client.response(Connection.t()) + def get_connection(client \\ WorkOS.client(), connection_id) do + WorkOS.Client.get(client, Connection, "/connections/:id", + opts: [ + path_params: [id: connection_id] + ] + ) + end + + @doc """ + Generates an OAuth 2.0 authorization URL. + + Parameter options: + + * `:organization` - The organization connection selector is used to initiate SSO for an Organization. + * `:connection` - The connection connection selector is used to initiate SSO for a Connection. + * `:redirect_uri` - A Redirect URI to return an authorized user to. (required) + * `:client_id` - This value can be obtained from the SSO Configuration page in the WorkOS dashboard. + * `:provider` - The provider connection selector is used to initiate SSO using an OAuth provider. + * `:state` - An optional parameter that can be used to encode arbitrary information to help restore application state between redirects. + * `:login_hint` - Can be used to pre-fill the username/email address field of the IdP sign-in page for the user, if you know their username ahead of time. + * `:domain_hint` - Can be used to pre-fill the domain field when initiating authentication with Microsoft OAuth, or with a GoogleSAML connection type. + + """ + @spec get_authorization_url(map()) :: {:ok, String.t()} | {:error, String.t()} + def get_authorization_url(params) + when is_map_key(params, :redirect_uri) and + (is_map_key(params, :connection) or is_map_key(params, :organization) or + is_map_key(params, :provider)) do + client_id = + params[:client_id] || WorkOS.client_id() || + raise "Missing required `client_id` parameter." + + defaults = %{ + client_id: client_id, + response_type: "code" + } + + query = + defaults + |> Map.merge(params) + |> Map.take( + [ + :client_id, + :redirect_uri, + :connection, + :organization, + :provider, + :state, + :login_hint, + :domain_hint + ] ++ Map.keys(defaults) + ) + |> URI.encode_query() + + {:ok, "#{WorkOS.base_url()}/sso/authorize?#{query}"} + end + + def get_authorization_url(_params), + do: + {:error, + "Incomplete arguments. Need to specify either a 'connection', 'organization', or 'provider'."} + + @doc """ + Gets an access token along with the user `Profile`. + + Parameter options: + + * `:code` - The authorization value which was passed back as a query parameter in the callback to the Redirect URI. (required) + + """ + @spec get_profile_and_token(String.t()) :: WorkOS.Client.response(ProfileAndToken.t()) + @spec get_profile_and_token(WorkOS.Client.t(), String.t()) :: + WorkOS.Client.response(ProfileAndToken.t()) + def get_profile_and_token(client \\ WorkOS.client(), code) do + WorkOS.Client.post(client, ProfileAndToken, "/sso/token", %{ + client_id: WorkOS.client_id(client), + client_secret: WorkOS.api_key(client), + grant_type: "authorization_code", + code: code + }) + end + + @doc """ + Gets a profile given an access token. + """ + @spec get_profile(String.t()) :: WorkOS.Client.response(Profile.t()) + @spec get_profile(WorkOS.Client.t(), String.t()) :: WorkOS.Client.response(Profile.t()) + def get_profile(client \\ WorkOS.client(), access_token) do + WorkOS.Client.get(client, Profile, "/sso/profile", + opts: [ + access_token: access_token + ] + ) + end +end diff --git a/lib/workos/sso/connection.ex b/lib/workos/sso/connection.ex new file mode 100644 index 0000000..061ecfc --- /dev/null +++ b/lib/workos/sso/connection.ex @@ -0,0 +1,47 @@ +defmodule WorkOS.SSO.Connection do + @moduledoc """ + WorkOS Connection struct. + """ + + alias WorkOS.Castable + alias WorkOS.SSO.Connection.Domain + + @behaviour WorkOS.Castable + + @type t() :: %__MODULE__{ + id: String.t(), + name: String.t(), + connection_type: String.t(), + state: String.t(), + domains: list(Domain.t()) | nil, + updated_at: String.t(), + created_at: String.t(), + organization_id: String.t() + } + + @enforce_keys [:id, :name, :connection_type, :state, :updated_at, :created_at, :organization_id] + defstruct [ + :id, + :name, + :connection_type, + :state, + :domains, + :updated_at, + :created_at, + :organization_id + ] + + @impl true + def cast(map) do + %__MODULE__{ + id: map["id"], + name: map["name"], + connection_type: map["connection_type"], + state: map["state"], + domains: Castable.cast_list(Domain, map["domains"]), + updated_at: map["updated_at"], + created_at: map["created_at"], + organization_id: map["organization_id"] + } + end +end diff --git a/lib/workos/sso/connection/domain.ex b/lib/workos/sso/connection/domain.ex new file mode 100644 index 0000000..1eeb1d6 --- /dev/null +++ b/lib/workos/sso/connection/domain.ex @@ -0,0 +1,29 @@ +defmodule WorkOS.SSO.Connection.Domain do + @moduledoc """ + WorkOS Domain Record struct. + """ + + @behaviour WorkOS.Castable + + @type t() :: %__MODULE__{ + id: String.t(), + object: String.t(), + domain: String.t() + } + + @enforce_keys [:id] + defstruct [ + :id, + :object, + :domain + ] + + @impl true + def cast(map) do + %__MODULE__{ + id: map["id"], + object: map["object"], + domain: map["domain"] + } + end +end diff --git a/lib/workos/sso/profile-and-token.ex b/lib/workos/sso/profile-and-token.ex new file mode 100644 index 0000000..990b426 --- /dev/null +++ b/lib/workos/sso/profile-and-token.ex @@ -0,0 +1,28 @@ +defmodule WorkOS.SSO.ProfileAndToken do + @moduledoc """ + WorkOS Profile and Token struct. + """ + + alias WorkOS.SSO.Profile + + @behaviour WorkOS.Castable + + @type t() :: %__MODULE__{ + access_token: String.t(), + profile: Profile.t() + } + + @enforce_keys [:access_token, :profile] + defstruct [ + :access_token, + :profile + ] + + @impl true + def cast(map) do + %__MODULE__{ + access_token: map["access_token"], + profile: map["profile"] + } + end +end diff --git a/lib/workos/sso/profile.ex b/lib/workos/sso/profile.ex new file mode 100644 index 0000000..1a85467 --- /dev/null +++ b/lib/workos/sso/profile.ex @@ -0,0 +1,49 @@ +defmodule WorkOS.SSO.Profile do + @moduledoc """ + WorkOS Profile struct. + """ + + @behaviour WorkOS.Castable + + @type t() :: %__MODULE__{ + id: String.t(), + idp_id: String.t(), + organization_id: String.t() | nil, + connection_id: String.t(), + connection_type: String.t(), + email: String.t(), + first_name: String.t() | nil, + last_name: String.t() | nil, + groups: [String.t()] | nil, + raw_attributes: %{String.t() => any()} | nil + } + + @enforce_keys [:id, :idp_id, :connection_id, :connection_type, :email] + defstruct [ + :id, + :idp_id, + :organization_id, + :connection_id, + :connection_type, + :email, + :first_name, + :last_name, + :groups, + :raw_attributes + ] + + @impl true + def cast(map) do + %__MODULE__{ + id: map["id"], + idp_id: map["idp_id"], + organization_id: map["organization_id"], + connection_id: map["connection_id"], + connection_type: map["connection_type"], + email: map["email"], + first_name: map["last_name"], + groups: map["groups"], + raw_attributes: map["raw_attributes"] + } + end +end diff --git a/lib/workos/sso/sso.ex b/lib/workos/sso/sso.ex deleted file mode 100644 index f6555fe..0000000 --- a/lib/workos/sso/sso.ex +++ /dev/null @@ -1,192 +0,0 @@ -defmodule WorkOS.SSO do - import WorkOS.API - require Logger - - @provider_values ["GoogleOAuth", "MicrosoftOAuth"] - - @moduledoc """ - The SSO module provides convenience methods for working with the WorkOS - SSO platform. You'll need a valid API key, a client ID, and to have - created an SSO connection on your WorkOS dashboard. - - @see https://docs.workos.com/sso/overview - """ - - @doc """ - Generate an Oauth2 authorization URL where your users will - authenticate using the configured SSO Identity Provider. - - ### Parameters - - params (map) - - domain (string) The domain for the relevant SSO Connection - configured on your WorkOS dashboard. One of provider, domain, - connection, or organization is required. - The domain is deprecated. - - provider (string) A provider name for an Identity Provider - configured on your WorkOS dashboard. Only 'GoogleOAuth' and - 'MicrosoftOAuth' are supported. - - connection (string) Unique identifier for a WorkOS Connection - - organization (string) Unique identifier for a WorkOS Organization - - client_id (string) Client ID for a WorkOS Environment. This - value can be found in the WorkOS dashboard. - - redirect_uri (string) The URI where users are directed - after completing the authentication step. Must match a - configured redirect URI on your WorkOS dashboard. - - state (string) An aribtrary state object - that is preserved and available to the client in the response. - - domain_hint (string) Used to pre-fill domain field when - authenticating with MicrosoftOAuth - - login_hint (string) Used to prefill the username/email field - when authenticating with GoogleOAuth or OpenID Connect - - ### Example - WorkOS.SSO.get_authorization_url(%{ - connection: "conn_123", - client_id: "project_01DG5TGK363GRVXP3ZS40WNGEZ", - redirect_uri: "https://workos.com/callback" - }) - """ - def get_authorization_url(params, opts \\ []) - - def get_authorization_url(%{provider: provider} = _params, _opts) - when provider not in @provider_values, - do: - raise(ArgumentError, - message: "#{provider} is not a valid value. `provider` must be in #{@provider_values}" - ) - - def get_authorization_url(params, opts) - when is_map_key(params, :domain) or is_map_key(params, :provider) or - is_map_key(params, :connection) or is_map_key(params, :organization) do - if is_map_key(params, :domain) do - Logger.warn("[DEPRECATION] `domain` is deprecated. Please use `organization` instead.") - end - - query = - process_params( - params, - [ - :domain, - :provider, - :connection, - :organization, - :client_id, - :redirect_uri, - :state, - :domain_hint, - :login_hint - ], - %{ - client_id: WorkOS.client_id(opts), - response_type: "code" - } - ) - |> URI.encode_query() - - {:ok, "#{WorkOS.base_url()}/sso/authorize?#{query}"} - end - - def get_authorization_url(_params, _opts), - do: - raise(ArgumentError, - message: "Either connection, domain, provider, or organization required in params" - ) - - @doc """ - Fetch the user profile details with an access token. - - ### Parameters - - access_token (string) An access token that can be exchanged for a user profile - - ### Example - WorkOS.SSO.get_profile("12345") - """ - def get_profile(access_token, opts \\ []) do - opts = opts |> Keyword.put(:access_token, access_token) - - get( - "/sso/profile", - %{}, - opts - ) - end - - @doc """ - Fetch the user profile details and access token for the authenticated SSO user using the code passed to your Redirect URI. - - ### Parameters - - code (string) The authorization code provided in the callback URL - - ### Example - WorkOS.SSO.get_profile_and_token("12345") - """ - def get_profile_and_token(code, opts \\ []) do - post( - "/sso/token", - %{ - code: code, - client_id: WorkOS.client_id(opts), - client_secret: WorkOS.api_key(opts), - grant_type: "authorization_code" - }, - opts - ) - end - - @doc """ - List connections - - ### Parameters - - params (map) - - connection_type (string - optional) Authentication service provider descriptor - - domain (string - optional) The domain of the connection to be retrieved - - organization_id (string - optional) The id of the organization of the connections to be retrieved - - limit (number - optional) Upper limit on the number of objects to return, between 1 and 100. The default value is 10 - - before (string - optional) An object ID that defines your place in the list - - after (string - optional) An object ID that defines your place in the list - - order ("asc" or "desc" - optional) Supported values are "asc" and "desc" for ascending and descending order respectively - - ### Example - WorkOS.SSO.list_connections() - """ - def list_connections(params \\ %{}, opts \\ []) do - query = - process_params(params, [ - :connection_type, - :domain, - :organization_id, - :limit, - :before, - :after, - :order - ]) - - get("/connections", query, opts) - end - - @doc """ - Get a connection - - ### Parameters - - connection (string) The ID of the connection to retrieve - - ### Example - WorkOS.SSO.get_connection(connection="conn_123") - """ - def get_connection(connection, opts \\ []) do - get("/connections/#{connection}", %{}, opts) - end - - @doc """ - Delete a connection - - ### Parameters - - connection (string) the ID of the connection to delete - - ### Example - WorkOS.SSO.delete_connection("conn_12345") - """ - def delete_connection(connection, opts \\ []) do - delete("/connections/#{connection}", %{}, opts) - end -end diff --git a/lib/workos/user_management.ex b/lib/workos/user_management.ex new file mode 100644 index 0000000..d9604ab --- /dev/null +++ b/lib/workos/user_management.ex @@ -0,0 +1,771 @@ +defmodule WorkOS.UserManagement do + @moduledoc """ + Manage User Management in WorkOS. + + @see https://workos.com/docs/reference/user-management + """ + + alias WorkOS.Empty + alias WorkOS.UserManagement.Authentication + alias WorkOS.UserManagement.EmailVerification.SendVerificationEmail + alias WorkOS.UserManagement.EmailVerification.VerifyEmail + alias WorkOS.UserManagement.Invitation + alias WorkOS.UserManagement.MagicAuth.SendMagicAuthCode + alias WorkOS.UserManagement.MultiFactor.AuthenticationFactor + alias WorkOS.UserManagement.MultiFactor.EnrollAuthFactor + alias WorkOS.UserManagement.OrganizationMembership + alias WorkOS.UserManagement.ResetPassword + alias WorkOS.UserManagement.User + + @doc """ + Generates an OAuth 2.0 authorization URL. + + Parameter options: + + * `:organization_id` - The `organization_id` connection selector is used to initiate SSO for an Organization. + * `:connection_id` - The `connection_id` connection selector is used to initiate SSO for a Connection. + * `:redirect_uri` - A Redirect URI to return an authorized user to. (required) + * `:client_id` - This value can be obtained from the SSO Configuration page in the WorkOS dashboard. + * `:provider` - The `provider` connection selector is used to initiate SSO using an OAuth-compatible provider. + * `:state` - An optional parameter that can be used to encode arbitrary information to help restore application state between redirects. + * `:login_hint` - Can be used to pre-fill the username/email address field of the IdP sign-in page for the user, if you know their username ahead of time. + * `:domain_hint` - Can be used to pre-fill the domain field when initiating authentication with Microsoft OAuth, or with a GoogleSAML connection type. + + """ + @spec get_authorization_url(map()) :: {:ok, String.t()} | {:error, String.t()} + def get_authorization_url(params) + when is_map_key(params, :redirect_uri) and + (is_map_key(params, :connection_id) or is_map_key(params, :organization_id) or + is_map_key(params, :provider)) do + client_id = + params[:client_id] || WorkOS.client_id() || + raise "Missing required `client_id` parameter." + + defaults = %{ + client_id: client_id, + response_type: "code" + } + + query = + defaults + |> Map.merge(params) + |> Map.take( + [ + :client_id, + :redirect_uri, + :connection_id, + :organization_id, + :provider, + :state, + :login_hint, + :domain_hint, + :domain + ] ++ Map.keys(defaults) + ) + |> URI.encode_query() + + {:ok, "#{WorkOS.base_url()}/sso/authorize?#{query}"} + end + + def get_authorization_url(_params), + do: + {:error, + "Incomplete arguments. Need to specify either a 'connection', 'organization', or 'provider'."} + + @doc """ + Gets a user. + """ + @spec get_user(String.t()) :: WorkOS.Client.response(User.t()) + @spec get_user(WorkOS.Client.t(), String.t()) :: WorkOS.Client.response(User.t()) + def get_user(client \\ WorkOS.client(), user_id) do + WorkOS.Client.get(client, User, "/user_management/users/:id", + opts: [ + path_params: [id: user_id] + ] + ) + end + + @doc """ + Lists all users. + + Parameter options: + + * `:email` - Filter Users by their email. + * `:organization_id` - Filter Users by the organization they are members of. + * `:limit` - Maximum number of records to return. Accepts values between 1 and 100. Default is 10. + * `:after` - Pagination cursor to receive records after a provided event ID. + * `:before` - An object ID that defines your place in the list. When the ID is not present, you are at the end of the list. + * `:order` - Order the results by the creation time. Supported values are "asc" and "desc" for showing older and newer records first respectively. + + """ + @spec list_users(WorkOS.Client.t(), map()) :: + WorkOS.Client.response(WorkOS.List.t(User.t())) + def list_users(client, opts) do + WorkOS.Client.get(client, WorkOS.List.of(User), "/user_management/users", + query: [ + email: opts[:email], + organization_id: opts[:organization_id], + limit: opts[:limit], + after: opts[:after], + before: opts[:before], + order: opts[:order] + ] + ) + end + + @spec list_users(map()) :: + WorkOS.Client.response(WorkOS.List.t(User.t())) + def list_users(opts \\ %{}) do + WorkOS.Client.get(WorkOS.client(), WorkOS.List.of(User), "/user_management/users", + query: [ + email: opts[:email], + organization_id: opts[:organization_id], + limit: opts[:limit], + after: opts[:after], + before: opts[:before], + order: opts[:order] + ] + ) + end + + @doc """ + Creates a user. + + Parameter options: + + * `:email` - The email address of the user. (required) + * `:domains` - The password to set for the user. + * `:first_name` - The user's first name. + * `:last_name` - The user's last name. + * `:email_verified` - Whether the user's email address was previously verified. + + """ + @spec create_user(map()) :: WorkOS.Client.response(User.t()) + @spec create_user(WorkOS.Client.t(), map()) :: + WorkOS.Client.response(User.t()) + def create_user(client \\ WorkOS.client(), opts) when is_map_key(opts, :email) do + WorkOS.Client.post( + client, + User, + "/user_management/users", + %{ + email: opts[:email], + domains: opts[:domains], + first_name: opts[:first_name], + last_name: opts[:last_name], + email_verified: opts[:email_verified] + } + ) + end + + @doc """ + Updates a user. + + Parameter options: + + * `:first_name` - The user's first name. + * `:last_name` - The user's last name. + * `:email_verified` - Whether the user's email address was previously verified. + * `:password` - The password to set for the user. + * `:password_hash` - The hashed password to set for the user, used when migrating from another user store. Mutually exclusive with password. + * `:password_hash_type` - The algorithm originally used to hash the password, used when providing a password_hash. Valid values are `bcrypt`. + + """ + @spec update_user(String.t(), map()) :: WorkOS.Client.response(User.t()) + @spec update_user(WorkOS.Client.t(), String.t(), map()) :: + WorkOS.Client.response(User.t()) + def update_user(client \\ WorkOS.client(), user_id, opts) do + WorkOS.Client.put(client, User, "/user_management/users/#{user_id}", %{ + first_name: opts[:first_name], + last_name: opts[:last_name], + email_verified: !!opts[:email_verified], + password: opts[:password], + password_hash: opts[:password_hash], + password_hash_type: opts[:password_hash_type] + }) + end + + @doc """ + Deletes a user. + """ + @spec delete_user(String.t()) :: WorkOS.Client.response(nil) + @spec delete_user(WorkOS.Client.t(), String.t()) :: WorkOS.Client.response(nil) + def delete_user(client \\ WorkOS.client(), user_id) do + WorkOS.Client.delete(client, Empty, "/user_management/users/:id", %{}, + opts: [ + path_params: [id: user_id] + ] + ) + end + + @doc """ + Authenticates a user with password. + + Parameter options: + + * `:email` - The email address of the user. (required) + * `:password` - The password of the user. (required) + * `:ip_address` - The IP address of the request from the user who is attempting to authenticate. + * `:user_agent` - The user agent of the request from the user who is attempting to authenticate. This should be the value of the User-Agent header. + + """ + @spec authenticate_with_password(map()) :: + WorkOS.Client.response(Authentication.t()) + @spec authenticate_with_password(WorkOS.Client.t(), map()) :: + WorkOS.Client.response(Authentication.t()) + def authenticate_with_password(client \\ WorkOS.client(), opts) + when is_map_key(opts, :email) and + is_map_key(opts, :password) do + WorkOS.Client.post( + client, + Authentication, + "/user_management/authenticate", + %{ + client_id: WorkOS.client_id(client), + client_secret: WorkOS.api_key(client), + grant_type: "password", + email: opts[:email], + password: opts[:password], + ip_address: opts[:ip_address], + user_agent: opts[:user_agent] + } + ) + end + + @doc """ + Authenticates an OAuth or SSO User. + + Parameter options: + + * `:code` - The authorization value which was passed back as a query parameter in the callback to the Redirect URI. (required) + * `:ip_address` - The IP address of the request from the user who is attempting to authenticate. + * `:user_agent` - The user agent of the request from the user who is attempting to authenticate. This should be the value of the User-Agent header. + + """ + @spec authenticate_with_code(map()) :: + WorkOS.Client.response(Authentication.t()) + @spec authenticate_with_code(WorkOS.Client.t(), map()) :: + WorkOS.Client.response(Authentication.t()) + def authenticate_with_code(client \\ WorkOS.client(), opts) + when is_map_key(opts, :code) do + WorkOS.Client.post( + client, + Authentication, + "/user_management/authenticate", + %{ + client_id: WorkOS.client_id(client), + client_secret: WorkOS.api_key(client), + grant_type: "authorization_code", + code: opts[:code], + ip_address: opts[:ip_address], + user_agent: opts[:user_agent] + } + ) + end + + @doc """ + Authenticates with Magic Auth. + + Parameter options: + + * `:code` - The one-time code that was emailed to the user. (required) + * `:email` - The email the User who will be authenticated. (required) + * `:link_authorization_code` - An authorization code used in a previous authenticate request that resulted in an existing user error response. + * `:ip_address` - The IP address of the request from the user who is attempting to authenticate. + * `:user_agent` - The user agent of the request from the user who is attempting to authenticate. This should be the value of the User-Agent header. + + """ + @spec authenticate_with_magic_auth(map()) :: + WorkOS.Client.response(Authentication.t()) + @spec authenticate_with_magic_auth(WorkOS.Client.t(), map()) :: + WorkOS.Client.response(Authentication.t()) + def authenticate_with_magic_auth(client \\ WorkOS.client(), opts) + when is_map_key(opts, :code) and + is_map_key(opts, :email) do + WorkOS.Client.post( + client, + Authentication, + "/user_management/authenticate", + %{ + client_id: WorkOS.client_id(client), + client_secret: WorkOS.api_key(client), + grant_type: "urn:workos:oauth:grant-type:magic-auth:code", + code: opts[:code], + email: opts[:email], + link_authorization_code: opts[:link_authorization_code], + ip_address: opts[:ip_address], + user_agent: opts[:user_agent] + } + ) + end + + @doc """ + Authenticates with Email Verification Code + + Parameter options: + + * `:code` - The one-time code that was emailed to the user. (required) + * `:pending_authentication_code` - The pending_authentication_token returned from an authentication attempt due to an unverified email address. (required) + * `:ip_address` - The IP address of the request from the user who is attempting to authenticate. + * `:user_agent` - The user agent of the request from the user who is attempting to authenticate. This should be the value of the User-Agent header. + + """ + @spec authenticate_with_email_verification(map()) :: + WorkOS.Client.response(Authentication.t()) + @spec authenticate_with_email_verification(WorkOS.Client.t(), map()) :: + WorkOS.Client.response(Authentication.t()) + def authenticate_with_email_verification(client \\ WorkOS.client(), opts) + when is_map_key(opts, :code) and + is_map_key(opts, :pending_authentication_code) do + WorkOS.Client.post( + client, + Authentication, + "/user_management/authenticate", + %{ + client_id: WorkOS.client_id(client), + client_secret: WorkOS.api_key(client), + grant_type: "urn:workos:oauth:grant-type:email-verification:code", + code: opts[:code], + pending_authentication_code: opts[:pending_authentication_code], + ip_address: opts[:ip_address], + user_agent: opts[:user_agent] + } + ) + end + + @doc """ + Authenticates with MFA TOTP + + Parameter options: + + * `:code` - The time-based-one-time-password generated by the Factor that was challenged. (required) + * `:authentication_challenge_id` - The unique ID of the authentication Challenge created for the TOTP Factor for which the user is enrolled. (required) + * `:pending_authentication_code` - The token returned from a failed authentication attempt due to MFA challenge. (required) + * `:ip_address` - The IP address of the request from the user who is attempting to authenticate. + * `:user_agent` - The user agent of the request from the user who is attempting to authenticate. This should be the value of the User-Agent header. + + """ + @spec authenticate_with_totp(map()) :: + WorkOS.Client.response(Authentication.t()) + @spec authenticate_with_totp(WorkOS.Client.t(), map()) :: + WorkOS.Client.response(Authentication.t()) + def authenticate_with_totp(client \\ WorkOS.client(), opts) + when is_map_key(opts, :code) and + is_map_key(opts, :authentication_challenge_id) and + is_map_key(opts, :pending_authentication_code) do + WorkOS.Client.post( + client, + Authentication, + "/user_management/authenticate", + %{ + client_id: WorkOS.client_id(client), + client_secret: WorkOS.api_key(client), + grant_type: "urn:workos:oauth:grant-type:mfa-totp", + code: opts[:code], + authentication_challenge_id: opts[:authentication_challenge_id], + pending_authentication_code: opts[:pending_authentication_code], + ip_address: opts[:ip_address], + user_agent: opts[:user_agent] + } + ) + end + + @doc """ + Authenticates with Selected Organization + + Parameter options: + + * `:pending_authentication_code` - The token returned from a failed authentication attempt due to organization selection being required. (required) + * `:organization_id` - The Organization ID the user selected. (required) + * `:ip_address` - The IP address of the request from the user who is attempting to authenticate. + * `:user_agent` - The user agent of the request from the user who is attempting to authenticate. This should be the value of the User-Agent header. + + """ + @spec authenticate_with_selected_organization(map()) :: + WorkOS.Client.response(Authentication.t()) + @spec authenticate_with_selected_organization(WorkOS.Client.t(), map()) :: + WorkOS.Client.response(Authentication.t()) + def authenticate_with_selected_organization(client \\ WorkOS.client(), opts) + when is_map_key(opts, :pending_authentication_code) and + is_map_key(opts, :organization_id) do + WorkOS.Client.post( + client, + Authentication, + "/user_management/authenticate", + %{ + client_id: WorkOS.client_id(client), + client_secret: WorkOS.api_key(client), + grant_type: "urn:workos:oauth:grant-type:organization-selection", + pending_authentication_code: opts[:pending_authentication_code], + organization_id: opts[:organization_id], + ip_address: opts[:ip_address], + user_agent: opts[:user_agent] + } + ) + end + + @doc """ + Creates a one-time Magic Auth code. + + Parameter options: + + * `:email` - The email address the one-time code will be sent to. (required) + + """ + @spec send_magic_auth_code(String.t()) :: WorkOS.Client.response(SendMagicAuthCode.t()) + @spec send_magic_auth_code(WorkOS.Client.t(), String.t()) :: + WorkOS.Client.response(SendMagicAuthCode.t()) + def send_magic_auth_code(client \\ WorkOS.client(), email) do + case WorkOS.Client.post( + client, + Empty, + "/user_management/magic_auth/send", + %{ + email: email + } + ) do + {:ok, _} -> :ok + {:error, reason} -> {:error, reason} + end + end + + @doc """ + Enrolls a user in a new Factor. + + Parameter options: + + * `:type` - The type of the factor to enroll. Only option available is `totp`. (required) + * `:totp_issuer` - For `totp` factors. Typically your application or company name, this helps users distinguish between factors in authenticator apps. + * `:totp_user` - For `totp` factors. Used as the account name in authenticator apps. Defaults to the user's email. + + """ + @spec enroll_auth_factor(String.t(), map()) :: WorkOS.Client.response(EnrollAuthFactor.t()) + @spec enroll_auth_factor(WorkOS.Client.t(), String.t(), map()) :: + WorkOS.Client.response(EnrollAuthFactor.t()) + def enroll_auth_factor(client \\ WorkOS.client(), user_id, opts) when is_map_key(opts, :type) do + WorkOS.Client.post( + client, + EnrollAuthFactor, + "/user_management/users/:id/auth_factors", + %{ + type: opts[:type], + totp_issuer: opts[:totp_issuer], + totp_user: opts[:totp_user] + }, + opts: [ + path_params: [id: user_id] + ] + ) + end + + @doc """ + Lists all auth factors of a user. + """ + @spec list_auth_factors(WorkOS.Client.t(), String.t()) :: + WorkOS.Client.response(WorkOS.List.t(AuthenticationFactor.t())) + def list_auth_factors(client \\ WorkOS.client(), user_id) do + WorkOS.Client.get( + client, + WorkOS.List.of(AuthenticationFactor), + "/user_management/users/:id/auth_factors", + opts: [ + path_params: [id: user_id] + ] + ) + end + + @doc """ + Sends verification email. + """ + @spec send_verification_email(String.t()) :: WorkOS.Client.response(SendVerificationEmail.t()) + @spec send_verification_email(WorkOS.Client.t(), String.t()) :: + WorkOS.Client.response(SendVerificationEmail.t()) + def send_verification_email(client \\ WorkOS.client(), user_id) do + WorkOS.Client.post( + client, + SendVerificationEmail, + "/user_management/users/:id/email_verification/send", + %{}, + opts: [ + path_params: [id: user_id] + ] + ) + end + + @doc """ + Verifies user email. + + Parameter options: + + * `:code` - The one-time code emailed to the user. (required) + + """ + @spec verify_email(String.t(), map()) :: WorkOS.Client.response(VerifyEmail.t()) + @spec verify_email(WorkOS.Client.t(), String.t(), map()) :: + WorkOS.Client.response(VerifyEmail.t()) + def verify_email(client \\ WorkOS.client(), user_id, opts) when is_map_key(opts, :code) do + WorkOS.Client.post( + client, + VerifyEmail, + "/user_management/users/:id/email_verification/confirm", + %{ + code: opts[:code] + }, + opts: [ + path_params: [id: user_id] + ] + ) + end + + @doc """ + Sends a password reset email to a user. + + Parameter options: + + * `:email` - The email of the user that wishes to reset their password. (required) + * `:password_reset_url` - The password to set for the user. (required) + + """ + @spec send_password_reset_email(map()) :: WorkOS.Client.response(OrganizationMembership.t()) + @spec send_password_reset_email(WorkOS.Client.t(), map()) :: + WorkOS.Client.response(OrganizationMembership.t()) + def send_password_reset_email(client \\ WorkOS.client(), opts) + when is_map_key(opts, :email) and is_map_key(opts, :password_reset_url) do + case WorkOS.Client.post(client, Empty, "/user_management/password_reset/send", %{ + email: opts[:email], + password_reset_url: opts[:password_reset_url] + }) do + {:ok, _} -> :ok + {:error, reason} -> {:error, reason} + end + end + + @doc """ + Resets password. + + Parameter options: + + * `:token` - The reset token emailed to the user. (required) + * `:new_password` - The new password to be set for the user. (required) + + """ + @spec reset_password(map()) :: WorkOS.Client.response(ResetPassword.t()) + @spec reset_password(WorkOS.Client.t(), map()) :: + WorkOS.Client.response(ResetPassword.t()) + def reset_password(client \\ WorkOS.client(), opts) + when is_map_key(opts, :token) and is_map_key(opts, :new_password) do + WorkOS.Client.post( + client, + ResetPassword, + "/user_management/password_reset/confirm", + %{ + token: opts[:token], + new_password: opts[:new_password] + } + ) + end + + @doc """ + Gets an organization membership. + """ + @spec get_organization_membership(String.t()) :: + WorkOS.Client.response(OrganizationMembership.t()) + @spec get_organization_membership(WorkOS.Client.t(), String.t()) :: + WorkOS.Client.response(OrganizationMembership.t()) + def get_organization_membership(client \\ WorkOS.client(), organization_membership_id) do + WorkOS.Client.get( + client, + OrganizationMembership, + "/user_management/organization_memberships/:id", + opts: [ + path_params: [id: organization_membership_id] + ] + ) + end + + @doc """ + Lists all organization memberships. + + Parameter options: + + * `:user_id` - The ID of the User. + * `:organization_id` - The ID of the Organization to which the user belongs to. + * `:limit` - Maximum number of records to return. Accepts values between 1 and 100. Default is 10. + * `:after` - Pagination cursor to receive records after a provided event ID. + * `:before` - An object ID that defines your place in the list. When the ID is not present, you are at the end of the list. + * `:order` - Order the results by the creation time. Supported values are "asc" and "desc" for showing older and newer records first respectively. + + """ + @spec list_organization_memberships(WorkOS.Client.t(), map()) :: + WorkOS.Client.response(WorkOS.List.t(OrganizationMembership.t())) + def list_organization_memberships(client, opts) do + WorkOS.Client.get( + client, + WorkOS.List.of(OrganizationMembership), + "/user_management/organization_memberships", + query: [ + user_id: opts[:user_id], + organization_id: opts[:organization_id], + limit: opts[:limit], + after: opts[:after], + before: opts[:before], + order: opts[:order] + ] + ) + end + + @spec list_organization_memberships(map()) :: + WorkOS.Client.response(WorkOS.List.t(OrganizationMembership.t())) + def list_organization_memberships(opts \\ %{}) do + WorkOS.Client.get( + WorkOS.client(), + WorkOS.List.of(OrganizationMembership), + "/user_management/organization_memberships", + query: [ + user_id: opts[:user_id], + organization_id: opts[:organization_id], + limit: opts[:limit], + after: opts[:after], + before: opts[:before], + order: opts[:order] + ] + ) + end + + @doc """ + Creates an organization membership. + + Parameter options: + + * `:user_id` - The ID of the User. (required) + * `:organization_id` - The ID of the Organization to which the user belongs to. (required) + + """ + @spec create_organization_membership(map()) :: + WorkOS.Client.response(OrganizationMembership.t()) + @spec create_organization_membership(WorkOS.Client.t(), map()) :: + WorkOS.Client.response(OrganizationMembership.t()) + def create_organization_membership(client \\ WorkOS.client(), opts) + when is_map_key(opts, :user_id) and is_map_key(opts, :organization_id) do + WorkOS.Client.post( + client, + OrganizationMembership, + "/user_management/organization_memberships", + %{ + user_id: opts[:user_id], + organization_id: opts[:organization_id] + } + ) + end + + @doc """ + Deletes an organization membership. + """ + @spec delete_organization_membership(String.t()) :: WorkOS.Client.response(nil) + @spec delete_organization_membership(WorkOS.Client.t(), String.t()) :: + WorkOS.Client.response(nil) + def delete_organization_membership(client \\ WorkOS.client(), organization_membership_id) do + WorkOS.Client.delete(client, Empty, "/user_management/organization_memberships/:id", %{}, + opts: [ + path_params: [id: organization_membership_id] + ] + ) + end + + @doc """ + Gets an invitation. + """ + @spec get_invitation(String.t()) :: WorkOS.Client.response(Invitation.t()) + @spec get_invitation(WorkOS.Client.t(), String.t()) :: WorkOS.Client.response(Invitation.t()) + def get_invitation(client \\ WorkOS.client(), invitation_id) do + WorkOS.Client.get(client, Invitation, "/user_management/invitations/:id", + opts: [ + path_params: [id: invitation_id] + ] + ) + end + + @doc """ + Lists all invitations. + + Parameter options: + + * `:email` - The email address of a recipient. + * `:organization_id` - The ID of the Organization that the recipient was invited to join. + * `:limit` - Maximum number of records to return. Accepts values between 1 and 100. Default is 10. + * `:after` - Pagination cursor to receive records after a provided event ID. + * `:before` - An object ID that defines your place in the list. When the ID is not present, you are at the end of the list. + * `:order` - Order the results by the creation time. Supported values are "asc" and "desc" for showing older and newer records first respectively. + + """ + @spec list_invitations(WorkOS.Client.t(), map()) :: + WorkOS.Client.response(WorkOS.List.t(Invitation.t())) + def list_invitations(client, opts) do + WorkOS.Client.get(client, WorkOS.List.of(Invitation), "/user_management/invitations", + query: [ + email: opts[:email], + organization_id: opts[:organization_id], + limit: opts[:limit], + after: opts[:after], + before: opts[:before], + order: opts[:order] + ] + ) + end + + @spec list_invitations(map()) :: + WorkOS.Client.response(WorkOS.List.t(Invitation.t())) + def list_invitations(opts \\ %{}) do + WorkOS.Client.get(WorkOS.client(), WorkOS.List.of(Invitation), "/user_management/invitations", + query: [ + email: opts[:email], + organization_id: opts[:organization_id], + limit: opts[:limit], + after: opts[:after], + before: opts[:before], + order: opts[:order] + ] + ) + end + + @doc """ + Sends an invitation. + + Parameter options: + + * `:email` - The email address of the recipient. (required) + * `:organization_id` - The ID of the Organization to which the recipient is being invited. + * `:expires_in_days` - The number of days the invitations will be valid for. + * `:inviter_user_id` - The ID of the User sending the invitation. + + """ + @spec send_invitation(map()) :: WorkOS.Client.response(Invitation.t()) + @spec send_invitation(WorkOS.Client.t(), map()) :: + WorkOS.Client.response(Invitation.t()) + def send_invitation(client \\ WorkOS.client(), opts) when is_map_key(opts, :email) do + WorkOS.Client.post( + client, + Invitation, + "/user_management/invitations", + %{ + email: opts[:email], + organization_id: opts[:organization_id], + expires_in_days: opts[:expires_in_days], + inviter_user_id: opts[:inviter_user_id] + } + ) + end + + @doc """ + Revokes an invitation. + """ + @spec revoke_invitation(String.t()) :: WorkOS.Client.response(Invitation.t()) + @spec revoke_invitation(WorkOS.Client.t(), String.t()) :: WorkOS.Client.response(Invitation.t()) + def revoke_invitation(client \\ WorkOS.client(), invitation_id) do + WorkOS.Client.post(client, Invitation, "/user_management/invitations/:id/revoke", %{}, + opts: [ + path_params: [id: invitation_id] + ] + ) + end +end diff --git a/lib/workos/user_management/authentication.ex b/lib/workos/user_management/authentication.ex new file mode 100644 index 0000000..c86fc40 --- /dev/null +++ b/lib/workos/user_management/authentication.ex @@ -0,0 +1,30 @@ +defmodule WorkOS.UserManagement.Authentication do + @moduledoc """ + WorkOS Authentication struct. + """ + + alias WorkOS.UserManagement.User + + @behaviour WorkOS.Castable + + @type t() :: %__MODULE__{ + user: User.t(), + organization_id: String.t() | nil + } + + @enforce_keys [ + :user + ] + defstruct [ + :user, + :organization_id + ] + + @impl true + def cast(map) do + %__MODULE__{ + user: map["user"], + organization_id: map["organization_id"] + } + end +end diff --git a/lib/workos/user_management/email_verification/send_verification_email.ex b/lib/workos/user_management/email_verification/send_verification_email.ex new file mode 100644 index 0000000..625c451 --- /dev/null +++ b/lib/workos/user_management/email_verification/send_verification_email.ex @@ -0,0 +1,27 @@ +defmodule WorkOS.UserManagement.EmailVerification.SendVerificationEmail do + @moduledoc """ + WorkOS Send Email struct. + """ + + alias WorkOS.UserManagement.User + + @behaviour WorkOS.Castable + + @type t() :: %__MODULE__{ + user: User.t() + } + + @enforce_keys [ + :user + ] + defstruct [ + :user + ] + + @impl true + def cast(map) do + %__MODULE__{ + user: map["user"] + } + end +end diff --git a/lib/workos/user_management/email_verification/verify_email.ex b/lib/workos/user_management/email_verification/verify_email.ex new file mode 100644 index 0000000..604a8e6 --- /dev/null +++ b/lib/workos/user_management/email_verification/verify_email.ex @@ -0,0 +1,27 @@ +defmodule WorkOS.UserManagement.EmailVerification.VerifyEmail do + @moduledoc """ + WorkOS Verify Email Code struct. + """ + + alias WorkOS.UserManagement.User + + @behaviour WorkOS.Castable + + @type t() :: %__MODULE__{ + user: User.t() + } + + @enforce_keys [ + :user + ] + defstruct [ + :user + ] + + @impl true + def cast(map) do + %__MODULE__{ + user: map["user"] + } + end +end diff --git a/lib/workos/user_management/invitation.ex b/lib/workos/user_management/invitation.ex new file mode 100644 index 0000000..21a0305 --- /dev/null +++ b/lib/workos/user_management/invitation.ex @@ -0,0 +1,54 @@ +defmodule WorkOS.UserManagement.Invitation do + @moduledoc """ + WorkOS Invitation struct. + """ + + @behaviour WorkOS.Castable + + @type t() :: %__MODULE__{ + id: String.t(), + email: String.t(), + state: String.t(), + token: String.t(), + organization_id: String.t() | nil, + accepted_at: String.t() | nil, + expires_at: String.t() | nil, + updated_at: String.t(), + created_at: String.t() + } + + @enforce_keys [ + :id, + :email, + :state, + :token, + :updated_at, + :created_at + ] + defstruct [ + :id, + :email, + :state, + :token, + :organization_id, + :accepted_at, + :expires_at, + :updated_at, + :created_at + ] + + @impl true + def cast(map) do + %__MODULE__{ + id: map["id"], + email: map["email"], + state: map["state"], + token: map["token"], + organization_id: map["organization_id"], + accepted_at: map["accepted_at"], + expires_at: map["expires_at"], + updated_at: map["updated_at"], + created_at: map["created_at"] + } + end +end diff --git a/lib/workos/user_management/magic_auth/send_magic_auth_code.ex b/lib/workos/user_management/magic_auth/send_magic_auth_code.ex new file mode 100644 index 0000000..1d85d13 --- /dev/null +++ b/lib/workos/user_management/magic_auth/send_magic_auth_code.ex @@ -0,0 +1,25 @@ +defmodule WorkOS.UserManagement.MagicAuth.SendMagicAuthCode do + @moduledoc """ + WorkOS Send Magic Auth Code struct. + """ + + @behaviour WorkOS.Castable + + @type t() :: %__MODULE__{ + email: String.t() + } + + @enforce_keys [ + :email + ] + defstruct [ + :email + ] + + @impl true + def cast(map) do + %__MODULE__{ + email: map["email"] + } + end +end diff --git a/lib/workos/user_management/multi_factor/authentication_challenge.ex b/lib/workos/user_management/multi_factor/authentication_challenge.ex new file mode 100644 index 0000000..4fcb6b3 --- /dev/null +++ b/lib/workos/user_management/multi_factor/authentication_challenge.ex @@ -0,0 +1,43 @@ +defmodule WorkOS.UserManagement.MultiFactor.AuthenticationChallenge do + @moduledoc """ + WorkOS Authentication Challenge struct. + """ + + @behaviour WorkOS.Castable + + @type t() :: %__MODULE__{ + id: String.t(), + code: String.t() | nil, + authentication_factor_id: String.t(), + expires_at: String.t() | nil, + updated_at: String.t(), + created_at: String.t() + } + + @enforce_keys [ + :id, + :authentication_factor_id, + :updated_at, + :created_at + ] + defstruct [ + :id, + :code, + :authentication_factor_id, + :expires_at, + :updated_at, + :created_at + ] + + @impl true + def cast(map) do + %__MODULE__{ + id: map["id"], + code: map["code"], + authentication_factor_id: map["authentication_factor_id"], + expires_at: map["expires_at"], + updated_at: map["updated_at"], + created_at: map["created_at"] + } + end +end diff --git a/lib/workos/user_management/multi_factor/authentication_factor.ex b/lib/workos/user_management/multi_factor/authentication_factor.ex new file mode 100644 index 0000000..e12cc7b --- /dev/null +++ b/lib/workos/user_management/multi_factor/authentication_factor.ex @@ -0,0 +1,49 @@ +defmodule WorkOS.UserManagement.MultiFactor.AuthenticationFactor do + @moduledoc """ + WorkOS Authentication Factor struct. + """ + + alias WorkOS.UserManagement.MultiFactor.SMS + alias WorkOS.UserManagement.MultiFactor.TOTP + + @behaviour WorkOS.Castable + + @type t() :: %__MODULE__{ + id: String.t(), + type: String.t(), + user_id: String.t() | nil, + sms: SMS.t() | nil, + totp: TOTP.t() | nil, + updated_at: String.t(), + created_at: String.t() + } + + @enforce_keys [ + :id, + :type, + :updated_at, + :created_at + ] + defstruct [ + :id, + :type, + :user_id, + :sms, + :totp, + :updated_at, + :created_at + ] + + @impl true + def cast(map) do + %__MODULE__{ + id: map["id"], + type: map["type"], + user_id: map["user_id"], + sms: map["sms"], + totp: map["totp"], + updated_at: map["updated_at"], + created_at: map["created_at"] + } + end +end diff --git a/lib/workos/user_management/multi_factor/enroll_auth_factor.ex b/lib/workos/user_management/multi_factor/enroll_auth_factor.ex new file mode 100644 index 0000000..f73a4c8 --- /dev/null +++ b/lib/workos/user_management/multi_factor/enroll_auth_factor.ex @@ -0,0 +1,32 @@ +defmodule WorkOS.UserManagement.MultiFactor.EnrollAuthFactor do + @moduledoc """ + WorkOS Enroll Auth Factor struct. + """ + + alias WorkOS.UserManagement.MultiFactor.AuthenticationChallenge + alias WorkOS.UserManagement.MultiFactor.AuthenticationFactor + + @behaviour WorkOS.Castable + + @type t() :: %__MODULE__{ + challenge: AuthenticationChallenge.t(), + factor: AuthenticationFactor.t() + } + + @enforce_keys [ + :challenge, + :factor + ] + defstruct [ + :challenge, + :factor + ] + + @impl true + def cast(map) do + %__MODULE__{ + challenge: map["challenge"], + factor: map["factor"] + } + end +end diff --git a/lib/workos/user_management/multi_factor/sms.ex b/lib/workos/user_management/multi_factor/sms.ex new file mode 100644 index 0000000..bf4a6e1 --- /dev/null +++ b/lib/workos/user_management/multi_factor/sms.ex @@ -0,0 +1,25 @@ +defmodule WorkOS.UserManagement.MultiFactor.SMS do + @moduledoc """ + WorkOS SMS struct. + """ + + @behaviour WorkOS.Castable + + @type t() :: %__MODULE__{ + phone_number: String.t() + } + + @enforce_keys [ + :phone_number + ] + defstruct [ + :phone_number + ] + + @impl true + def cast(map) do + %__MODULE__{ + phone_number: map["phone_number"] + } + end +end diff --git a/lib/workos/user_management/multi_factor/totp.ex b/lib/workos/user_management/multi_factor/totp.ex new file mode 100644 index 0000000..d5c9653 --- /dev/null +++ b/lib/workos/user_management/multi_factor/totp.ex @@ -0,0 +1,41 @@ +defmodule WorkOS.UserManagement.MultiFactor.TOTP do + @moduledoc """ + WorkOS TOTP struct. + """ + + @behaviour WorkOS.Castable + + @type t() :: %__MODULE__{ + issuer: String.t(), + user: String.t(), + secret: String.t(), + qr_code: String.t(), + uri: String.t() + } + + @enforce_keys [ + :issuer, + :user, + :secret, + :qr_code, + :uri + ] + defstruct [ + :issuer, + :user, + :secret, + :qr_code, + :uri + ] + + @impl true + def cast(map) do + %__MODULE__{ + issuer: map["issuer"], + user: map["user"], + secret: map["secret"], + qr_code: map["qr_code"], + uri: map["uri"] + } + end +end diff --git a/lib/workos/user_management/organization_membership.ex b/lib/workos/user_management/organization_membership.ex new file mode 100644 index 0000000..6d59b47 --- /dev/null +++ b/lib/workos/user_management/organization_membership.ex @@ -0,0 +1,41 @@ +defmodule WorkOS.UserManagement.OrganizationMembership do + @moduledoc """ + WorkOS Organization Membership struct. + """ + + @behaviour WorkOS.Castable + + @type t() :: %__MODULE__{ + id: String.t(), + user_id: String.t(), + organization_id: String.t(), + updated_at: String.t(), + created_at: String.t() + } + + @enforce_keys [ + :id, + :user_id, + :organization_id, + :updated_at, + :created_at + ] + defstruct [ + :id, + :user_id, + :organization_id, + :updated_at, + :created_at + ] + + @impl true + def cast(map) do + %__MODULE__{ + id: map["id"], + user_id: map["user_id"], + organization_id: map["organization_id"], + updated_at: map["updated_at"], + created_at: map["created_at"] + } + end +end diff --git a/lib/workos/user_management/reset_password.ex b/lib/workos/user_management/reset_password.ex new file mode 100644 index 0000000..ec0892b --- /dev/null +++ b/lib/workos/user_management/reset_password.ex @@ -0,0 +1,27 @@ +defmodule WorkOS.UserManagement.ResetPassword do + @moduledoc """ + WorkOS Reset Password struct. + """ + + alias WorkOS.UserManagement.User + + @behaviour WorkOS.Castable + + @type t() :: %__MODULE__{ + user: User.t() + } + + @enforce_keys [ + :user + ] + defstruct [ + :user + ] + + @impl true + def cast(map) do + %__MODULE__{ + user: map["user"] + } + end +end diff --git a/lib/workos/user_management/user.ex b/lib/workos/user_management/user.ex new file mode 100644 index 0000000..ece7d3e --- /dev/null +++ b/lib/workos/user_management/user.ex @@ -0,0 +1,47 @@ +defmodule WorkOS.UserManagement.User do + @moduledoc """ + WorkOS User struct. + """ + + @behaviour WorkOS.Castable + + @type t() :: %__MODULE__{ + id: String.t(), + email: String.t(), + email_verified: boolean(), + first_name: String.t() | nil, + last_name: String.t() | nil, + updated_at: String.t(), + created_at: String.t() + } + + @enforce_keys [ + :id, + :email, + :email_verified, + :updated_at, + :created_at + ] + defstruct [ + :id, + :email, + :email_verified, + :first_name, + :last_name, + :updated_at, + :created_at + ] + + @impl true + def cast(map) do + %__MODULE__{ + id: map["id"], + email: map["email"], + email_verified: map["email_verified"], + first_name: map["first_name"], + last_name: map["last_name"], + updated_at: map["updated_at"], + created_at: map["created_at"] + } + end +end diff --git a/lib/workos/webhooks/webhooks.ex b/lib/workos/webhooks.ex similarity index 96% rename from lib/workos/webhooks/webhooks.ex rename to lib/workos/webhooks.ex index dbb38d7..cc73a6b 100644 --- a/lib/workos/webhooks/webhooks.ex +++ b/lib/workos/webhooks.ex @@ -1,10 +1,10 @@ defmodule WorkOS.Webhooks do @moduledoc """ - The Webhooks module provides convenience methods for working with WorkOS webhooks. - Creates a WorkOS Webhook Event from the webhook's payload if signature is valid. + Manage timestamp and signature validation of Webhooks in WorkOS. See https://workos.com/docs/webhooks """ + alias WorkOS.Webhooks.Event @three_minute_default_tolerance 60 * 3 diff --git a/lib/workos/webhooks/event.ex b/lib/workos/webhooks/event.ex index df8eead..b3875d5 100644 --- a/lib/workos/webhooks/event.ex +++ b/lib/workos/webhooks/event.ex @@ -2,9 +2,10 @@ defmodule WorkOS.Webhooks.Event do @moduledoc """ Module to represent a webhook event """ + defstruct [:id, :event, :data] - @spec new(payload :: String.t()) :: __MODULE__ + @spec new(map) :: %__MODULE__{} def new(payload) do processed_map = [:id, :event, :data] diff --git a/mix.exs b/mix.exs index 9018307..5431145 100755 --- a/mix.exs +++ b/mix.exs @@ -1,75 +1,144 @@ defmodule WorkOS.MixProject do use Mix.Project - @version "0.4.0" + @version "1.0.0" @source_url "https://github.com/workos/workos-elixir" def project do [ - name: "WorkOS SDK for Elixir", app: :workos, version: @version, + name: "WorkOS SDK for Elixir", elixir: "~> 1.11", + elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, description: description(), package: package(), - deps: deps(), - source_url: "https://github.com/workos-inc/workos-elixir/", - homepage_url: "https://workos.com", docs: docs(), + deps: deps(), source_ref: "#{@version}", - source_url: @source_url + dialyzer: [ + flags: [:unmatched_returns, :error_handling, :extra_return], + plt_file: {:no_warn, "plts/dialyzer.plt"}, + plt_core_path: "plts", + plt_add_deps: :app_tree, + plt_add_apps: [:mix, :ex_unit] + ] ] end - def application do + defp description do + """ + Official Elixir SDK for interacting with the WorkOS API. + """ + end + + defp package() do [ - extra_applications: [:logger], - env: env() + description: description(), + licenses: ["MIT"], + maintainers: [ + "Mark Tran", + "Laura Beatris", + "Blair Lunceford", + "Jacobia Johnson" + ], + links: %{ + "GitHub" => @source_url, + "WorkOS" => "https://workos.com", + "Elixir Example" => "https://github.com/workos/elixir-example-applications" + } ] end - defp deps do + # Run "mix help compile.app" to learn about applications. + def application do [ - {:tesla, "~> 1.4"}, - {:hackney, "~> 1.18.0"}, - {:jason, ">= 1.0.0"}, - {:plug_crypto, "~> 1.0"}, - {:ex_doc, "~> 0.23", only: :dev, runtime: false}, - {:credo, "~> 1.6", only: [:dev, :test], runtime: false} + extra_applications: [:logger] ] end - defp description do - """ - WorkOS SDK for Elixir. - """ - end + # Specifies which paths to compile per environment. + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] - defp docs do + # Run "mix help deps" to learn about dependencies. + defp deps do [ - # The main page in the docs - main: "readme", - extras: ["README.md"] + {:tesla, "~> 1.4"}, + {:jason, "~> 1.4.1"}, + {:hackney, "~> 1.9"}, + {:plug_crypto, "~> 2.0"}, + {:ex_doc, "~> 0.23", only: :dev, runtime: false}, + {:credo, "~> 1.6", only: [:dev, :test], runtime: false}, + {:dialyxir, "~> 1.0", only: [:test, :dev], runtime: false} ] end - defp env do + defp docs() do [ - host: "api.workos.com" + extras: [ + "README.md": [title: "Overview"] + ], + main: "readme", + source_url: @source_url, + source_ref: "v#{@version}", + groups_for_modules: groups_for_modules() ] end - defp package do + defp groups_for_modules() do [ - files: ["lib", "LICENSE*", "mix.exs", "README*"], - licenses: ["MIT"], - links: %{ - "GitHub" => @source_url, - "Documentation" => "https://workos.com/docs", - "Homepage" => "https://workos.com" - }, - maintainers: ["Laura Beatris", "Blair Lunceford", "Jacobia Johnson"] + "Core API": [ + WorkOS.AuditLogs, + WorkOS.DirectorySync, + WorkOS.Events, + WorkOS.OrganizationDomains, + WorkOS.Organizations, + WorkOS.Passwordless, + WorkOS.Portal, + WorkOS.SSO, + WorkOS.UserManagement, + WorkOS.Webhooks + ], + "Response Structs": [ + WorkOS.AuditLogs.Export, + WorkOS.DirectorySync.Directory, + WorkOS.DirectorySync.Directory.Group, + WorkOS.DirectorySync.Directory.User, + WorkOS.Events.Event, + WorkOS.Organizations.Organization, + WorkOS.Organizations.Organization.Domain, + WorkOS.OrganizationDomains.OrganizationDomain, + WorkOS.Passwordless.Session, + WorkOS.Passwordless.Session.Send, + WorkOS.Portal.Link, + WorkOS.SSO.Connection, + WorkOS.SSO.Connection.Domain, + WorkOS.SSO.Profile, + WorkOS.SSO.ProfileAndToken, + WorkOS.UserManagement.Authentication, + WorkOS.UserManagement.EmailVerification.SendVerificationEmail, + WorkOS.UserManagement.EmailVerification.VerifyEmail, + WorkOS.UserManagement.EnrollAuthFactor, + WorkOS.UserManagement.Invitation, + WorkOS.UserManagement.MagicAuth.SendMagicAuthCode, + WorkOS.UserManagement.MultiFactor.AuthenticationChallenge, + WorkOS.UserManagement.MultiFactor.AuthenticationFactor, + WorkOS.UserManagement.MultiFactor.EnrollAuthFactor, + WorkOS.UserManagement.MultiFactor.SMS, + WorkOS.UserManagement.MultiFactor.TOTP, + WorkOS.UserManagement.OrganizationMembership, + WorkOS.UserManagement.ResetPassword, + WorkOS.Webhooks.Event, + WorkOS.Empty, + WorkOS.Error, + WorkOS.List + ], + "API Client": [ + WorkOS.Client, + WorkOS.Client.TeslaClient + ] ] end end diff --git a/mix.lock b/mix.lock index d2513f8..4591156 100644 --- a/mix.lock +++ b/mix.lock @@ -1,23 +1,25 @@ %{ "bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"}, - "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, - "credo": {:hex, :credo, "1.6.7", "323f5734350fd23a456f2688b9430e7d517afb313fbd38671b8a4449798a7854", [:mix], [{:bunt, "~> 0.2.1", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "41e110bfb007f7eda7f897c10bf019ceab9a0b269ce79f015d54b0dcf4fc7dd3"}, - "earmark_parser": {:hex, :earmark_parser, "1.4.25", "2024618731c55ebfcc5439d756852ec4e85978a39d0d58593763924d9a15916f", [:mix], [], "hexpm", "56749c5e1c59447f7b7a23ddb235e4b3defe276afc220a6227237f3efe83f51e"}, - "ex_doc": {:hex, :ex_doc, "0.28.4", "001a0ea6beac2f810f1abc3dbf4b123e9593eaa5f00dd13ded024eae7c523298", [:mix], [{:earmark_parser, "~> 1.4.19", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "bf85d003dd34911d89c8ddb8bda1a958af3471a274a4c2150a9c01c78ac3f8ed"}, + "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, + "credo": {:hex, :credo, "1.7.1", "6e26bbcc9e22eefbff7e43188e69924e78818e2fe6282487d0703652bc20fd62", [:mix], [{:bunt, "~> 0.2.1", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "e9871c6095a4c0381c89b6aa98bc6260a8ba6addccf7f6a53da8849c748a58a2"}, + "dialyxir": {:hex, :dialyxir, "1.4.2", "764a6e8e7a354f0ba95d58418178d486065ead1f69ad89782817c296d0d746a5", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "516603d8067b2fd585319e4b13d3674ad4f314a5902ba8130cd97dc902ce6bbd"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"}, + "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, + "ex_doc": {:hex, :ex_doc, "0.30.9", "d691453495c47434c0f2052b08dd91cc32bc4e1a218f86884563448ee2502dd2", [:mix], [{:earmark_parser, "~> 1.4.31", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "d7aaaf21e95dc5cddabf89063327e96867d00013963eadf2c6ad135506a8bc10"}, "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, - "hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~>2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"}, - "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, - "jason": {:hex, :jason, "1.3.0", "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac"}, - "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, - "makeup_elixir": {:hex, :makeup_elixir, "0.16.0", "f8c570a0d33f8039513fbccaf7108c5d750f47d8defd44088371191b76492b0b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "28b2cbdc13960a46ae9a8858c4bebdec3c9a6d7b4b9e7f4ed1502f8159f338e7"}, - "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, + "hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"}, + "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, + "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, + "makeup": {:hex, :makeup, "1.1.1", "fa0bc768698053b2b3869fa8a62616501ff9d11a562f3ce39580d60860c3a55e", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "5dc62fbdd0de44de194898b6710692490be74baa02d9d108bc29f007783b0b48"}, + "makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"}, + "makeup_erlang": {:hex, :makeup_erlang, "0.1.3", "d684f4bac8690e70b06eb52dad65d26de2eefa44cd19d64a8095e1417df7c8fd", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "b78dc853d2e670ff6390b605d807263bf606da3c82be37f9d7f68635bd886fc9"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, - "mime": {:hex, :mime, "2.0.2", "0b9e1a4c840eafb68d820b0e2158ef5c49385d17fb36855ac6e7e087d4b1dcc5", [:mix], [], "hexpm", "e6a3f76b4c277739e36c2e21a2c640778ba4c3846189d5ab19f97f126df5f9b7"}, + "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, - "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, - "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, - "plug_crypto": {:hex, :plug_crypto, "1.2.3", "8f77d13aeb32bfd9e654cb68f0af517b371fb34c56c9f2b58fe3df1235c1251a", [:mix], [], "hexpm", "b5672099c6ad5c202c45f5a403f21a3411247f164e4a8fab056e5cd8a290f4a2"}, - "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, - "tesla": {:hex, :tesla, "1.4.4", "bb89aa0c9745190930366f6a2ac612cdf2d0e4d7fff449861baa7875afd797b2", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.3", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, "~> 1.3", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "d5503a49f9dec1b287567ea8712d085947e247cb11b06bc54adb05bfde466457"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, + "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, + "plug_crypto": {:hex, :plug_crypto, "2.0.0", "77515cc10af06645abbfb5e6ad7a3e9714f805ae118fa1a70205f80d2d70fe73", [:mix], [], "hexpm", "53695bae57cc4e54566d993eb01074e4d894b65a3766f1c43e2c61a1b0f45ea9"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, + "tesla": {:hex, :tesla, "1.8.0", "d511a4f5c5e42538d97eef7c40ec4f3e44effdc5068206f42ed859e09e51d1fd", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "10501f360cd926a309501287470372af1a6e1cbed0f43949203a4c13300bc79f"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, } diff --git a/test/support/audit_logs_client_mock.ex b/test/support/audit_logs_client_mock.ex new file mode 100644 index 0000000..a6567db --- /dev/null +++ b/test/support/audit_logs_client_mock.ex @@ -0,0 +1,86 @@ +defmodule WorkOS.AuditLogs.ClientMock do + @moduledoc false + + import ExUnit.Assertions, only: [assert: 1] + + def create_event(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :post + assert request.url == "#{WorkOS.base_url()}/audit_logs/events" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + assert Enum.find(request.headers, fn {header, _} -> header == "Idempotency-Key" end) != nil + + body = Jason.decode!(request.body) + + for {field, value} <- + Keyword.get(opts, :assert_fields, []) |> Keyword.delete(:idempotency_key) do + assert body[to_string(field)] == value + end + + {status, body} = Keyword.get(opts, :respond_with, {200, %{}}) + %Tesla.Env{status: status, body: body} + end) + end + + def create_export(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :post + assert request.url == "#{WorkOS.base_url()}/audit_logs/exports" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + body = Jason.decode!(request.body) + + for {field, value} <- Keyword.get(opts, :assert_fields, []) do + assert body[to_string(field)] == value + end + + success_body = %{ + "object" => "audit_log_export", + "id" => "audit_log_export_1234", + "state" => "pending", + "url" => nil, + "created_at" => "2022-02-15T15:26:53.274Z", + "updated_at" => "2022-02-15T15:26:53.274Z" + } + + {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) + %Tesla.Env{status: status, body: body} + end) + end + + def get_export(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + audit_log_export_id = + opts |> Keyword.get(:assert_fields) |> Keyword.get(:audit_log_export_id) + + assert request.method == :get + assert request.url == "#{WorkOS.base_url()}/audit_logs/exports/#{audit_log_export_id}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + success_body = %{ + "id" => audit_log_export_id, + "object" => "audit_logo_export", + "state" => "pending", + "url" => nil, + "created_at" => "2023-07-17T20:07:20.055Z", + "updated_at" => "2023-07-17T20:07:20.055Z" + } + + {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) + %Tesla.Env{status: status, body: body} + end) + end +end diff --git a/test/support/directory_sync_client_mock.ex b/test/support/directory_sync_client_mock.ex new file mode 100644 index 0000000..35de0dd --- /dev/null +++ b/test/support/directory_sync_client_mock.ex @@ -0,0 +1,209 @@ +defmodule WorkOS.DirectorySync.ClientMock do + @moduledoc false + + import ExUnit.Assertions, only: [assert: 1] + + @directory_group_response %{ + "id" => "dir_grp_123", + "object" => "directory_group", + "idp_id" => "123", + "directory_id" => "dir_123", + "organization_id" => "org_123", + "name" => "Foo Group", + "created_at" => "2021-10-27 15:21:50.640958", + "updated_at" => "2021-12-13 12:15:45.531847", + "raw_attributes" => %{ + "foo" => "bar" + } + } + + @directory_user_response %{ + "id" => "user_123", + "object" => "directory_user", + "custom_attributes" => %{ + "custom" => true + }, + "directory_id" => "dir_123", + "organization_id" => "org_123", + "emails" => [ + %{ + "primary" => true, + "type" => "type", + "value" => "jonsnow@workos.com" + } + ], + "first_name" => "Jon", + "groups" => [@directory_group_response], + "idp_id" => "idp_foo", + "last_name" => "Snow", + "job_title" => "Knight of the Watch", + "raw_attributes" => %{}, + "state" => "active", + "username" => "jonsnow", + "created_at" => "2023-07-17T20:07:20.055Z", + "updated_at" => "2023-07-17T20:07:20.055Z" + } + + def get_directory(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + directory_id = opts |> Keyword.get(:assert_fields) |> Keyword.get(:directory_id) + assert request.method == :get + assert request.url == "#{WorkOS.base_url()}/directories/#{directory_id}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + success_body = %{ + "id" => directory_id, + "organization_id" => "org_123", + "name" => "Foo", + "domain" => "foo-corp.com", + "object" => "directory", + "state" => "linked", + "external_key" => "9asBRBV", + "type" => "okta scim v1.1", + "created_at" => "2023-07-17T20:07:20.055Z", + "updated_at" => "2023-07-17T20:07:20.055Z" + } + + {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) + %Tesla.Env{status: status, body: body} + end) + end + + def list_directories(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :get + assert request.url == "#{WorkOS.base_url()}/directories" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + success_body = %{ + "data" => [ + %{ + "id" => "directory_123", + "organization_id" => "org_123", + "name" => "Foo", + "domain" => "foo-corp.com", + "object" => "directory", + "state" => "linked", + "external_key" => "9asBRBV", + "type" => "okta scim v1.1", + "created_at" => "2023-07-17T20:07:20.055Z", + "updated_at" => "2023-07-17T20:07:20.055Z" + } + ], + "list_metadata" => %{ + "before" => "before-id", + "after" => "after-id" + } + } + + {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) + %Tesla.Env{status: status, body: body} + end) + end + + def delete_directory(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + directory_id = opts |> Keyword.get(:assert_fields) |> Keyword.get(:directory_id) + assert request.method == :delete + assert request.url == "#{WorkOS.base_url()}/directories/#{directory_id}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + {status, body} = Keyword.get(opts, :respond_with, {204, %{}}) + %Tesla.Env{status: status, body: body} + end) + end + + def get_user(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + directory_user_id = opts |> Keyword.get(:assert_fields) |> Keyword.get(:directory_user_id) + assert request.method == :get + assert request.url == "#{WorkOS.base_url()}/directory_users/#{directory_user_id}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + {status, body} = Keyword.get(opts, :respond_with, {200, @directory_user_response}) + %Tesla.Env{status: status, body: body} + end) + end + + def list_users(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :get + assert request.url == "#{WorkOS.base_url()}/directory_users" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + success_body = %{ + "data" => [ + @directory_user_response + ], + "list_metadata" => %{ + "before" => "before-id", + "after" => "after-id" + } + } + + {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) + %Tesla.Env{status: status, body: body} + end) + end + + def get_group(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + directory_group_id = opts |> Keyword.get(:assert_fields) |> Keyword.get(:directory_group_id) + assert request.method == :get + assert request.url == "#{WorkOS.base_url()}/directory_groups/#{directory_group_id}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + {status, body} = Keyword.get(opts, :respond_with, {200, @directory_group_response}) + %Tesla.Env{status: status, body: body} + end) + end + + def list_groups(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :get + assert request.url == "#{WorkOS.base_url()}/directory_groups" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + success_body = %{ + "data" => [ + @directory_group_response + ], + "list_metadata" => %{ + "before" => "before-id", + "after" => "after-id" + } + } + + {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) + %Tesla.Env{status: status, body: body} + end) + end +end diff --git a/test/support/events_client_mock.ex b/test/support/events_client_mock.ex new file mode 100644 index 0000000..c1746ba --- /dev/null +++ b/test/support/events_client_mock.ex @@ -0,0 +1,42 @@ +defmodule WorkOS.Events.ClientMock do + @moduledoc false + + import ExUnit.Assertions, only: [assert: 1] + + def list_events(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :get + assert request.url == "#{WorkOS.base_url()}/events" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + success_body = %{ + "data" => [ + %{ + "id" => "event_01234ABCD", + "created_at" => "2020-05-06 04:21:48.649164", + "event" => "connection.activated", + "data" => %{ + "object" => "connection", + "id" => "conn_01234ABCD", + "organization_id" => "org_1234", + "name" => "Connection", + "connection_type" => "OktaSAML", + "state" => "active", + "domains" => [], + "created_at" => "2020-05-06 04:21:48.649164", + "updated_at" => "2020-05-06 04:21:48.649164" + } + } + ], + "list_metadata" => %{} + } + + {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) + %Tesla.Env{status: status, body: body} + end) + end +end diff --git a/test/support/mfa_client_mock.ex b/test/support/mfa_client_mock.ex new file mode 100644 index 0000000..9a7a6f6 --- /dev/null +++ b/test/support/mfa_client_mock.ex @@ -0,0 +1,151 @@ +defmodule WorkOS.MFA.ClientMock do + @moduledoc false + + import ExUnit.Assertions, only: [assert: 1] + + @authentication_challenge_mock %{ + "object" => "authentication_challenge", + "id" => "auth_challenge_1234", + "created_at" => "2022-03-15T20:39:19.892Z", + "updated_at" => "2022-03-15T20:39:19.892Z", + "expires_at" => "2022-03-15T21:39:19.892Z", + "code" => "12345", + "authentication_factor_id" => "auth_factor_1234" + } + + @authentication_factor_mock %{ + "object" => "authentication_factor", + "id" => "auth_factor_1234", + "created_at" => "2022-03-15T20:39:19.892Z", + "updated_at" => "2022-03-15T20:39:19.892Z", + "type" => "totp", + "totp" => %{ + "issuer" => "WorkOS", + "user" => "some_user" + } + } + + def enroll_factor(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :post + + assert request.url == + "#{WorkOS.base_url()}/auth/factors/enroll" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + body = Jason.decode!(request.body) + + for {field, value} <- Keyword.get(opts, :assert_fields, []) do + assert body[to_string(field)] == value + end + + success_body = @authentication_factor_mock + + {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) + %Tesla.Env{status: status, body: body} + end) + end + + def challenge_factor(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + authentication_factor_id = + opts |> Keyword.get(:assert_fields) |> Keyword.get(:authentication_factor_id) + + assert request.method == :post + + assert request.url == + "#{WorkOS.base_url()}/auth/factors/#{authentication_factor_id}/challenge" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + body = Jason.decode!(request.body) + + for {field, value} <- + Keyword.get(opts, :assert_fields, []) |> Keyword.delete(:authentication_factor_id) do + assert body[to_string(field)] == value + end + + success_body = @authentication_challenge_mock + + {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) + %Tesla.Env{status: status, body: body} + end) + end + + def verify_challenge(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + authentication_challenge_id = + opts |> Keyword.get(:assert_fields) |> Keyword.get(:authentication_challenge_id) + + assert request.method == :post + + assert request.url == + "#{WorkOS.base_url()}/auth/challenges/#{authentication_challenge_id}/verify" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + body = Jason.decode!(request.body) + + for {field, value} <- + Keyword.get(opts, :assert_fields, []) |> Keyword.delete(:authentication_challenge_id) do + assert body[to_string(field)] == value + end + + success_body = %{ + "challenge" => @authentication_challenge_mock, + "valid" => true + } + + {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) + %Tesla.Env{status: status, body: body} + end) + end + + def get_factor(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + authentication_factor_id = + opts |> Keyword.get(:assert_fields) |> Keyword.get(:authentication_factor_id) + + assert request.method == :get + assert request.url == "#{WorkOS.base_url()}/auth/factors/#{authentication_factor_id}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + success_body = @authentication_factor_mock + + {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) + %Tesla.Env{status: status, body: body} + end) + end + + def delete_factor(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + authentication_factor_id = + opts |> Keyword.get(:assert_fields) |> Keyword.get(:authentication_factor_id) + + assert request.method == :delete + assert request.url == "#{WorkOS.base_url()}/auth/factors/#{authentication_factor_id}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + {status, body} = Keyword.get(opts, :respond_with, {204, %{}}) + %Tesla.Env{status: status, body: body} + end) + end +end diff --git a/test/support/organization_domains_client_mock.ex b/test/support/organization_domains_client_mock.ex new file mode 100644 index 0000000..9c12475 --- /dev/null +++ b/test/support/organization_domains_client_mock.ex @@ -0,0 +1,80 @@ +defmodule WorkOS.OrganizationDomains.ClientMock do + @moduledoc false + + import ExUnit.Assertions, only: [assert: 1] + + @organization_domain_mock %{ + "object" => "organization_domain", + "id" => "org_domain_01HCZRAP3TPQ0X0DKJHR32TATG", + "domain" => "workos.com", + "state" => "verified", + "verification_token" => nil, + "verification_strategy" => "manual" + } + + def get_organization_domain(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + organization_domain_id = + opts |> Keyword.get(:assert_fields) |> Keyword.get(:organization_domain_id) + + assert request.method == :get + assert request.url == "#{WorkOS.base_url()}/organization_domains/#{organization_domain_id}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + success_body = @organization_domain_mock + + {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) + %Tesla.Env{status: status, body: body} + end) + end + + def create_organization_domain(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :post + assert request.url == "#{WorkOS.base_url()}/organization_domains" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + body = Jason.decode!(request.body) + + for {field, value} <- + Keyword.get(opts, :assert_fields, []) do + assert body[to_string(field)] == value + end + + success_body = @organization_domain_mock + + {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) + %Tesla.Env{status: status, body: body} + end) + end + + def verify_organization_domain(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + organization_domain_id = + opts |> Keyword.get(:assert_fields) |> Keyword.get(:organization_domain_id) + + assert request.method == :post + + assert request.url == + "#{WorkOS.base_url()}/organization_domains/#{organization_domain_id}/verify" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + success_body = @organization_domain_mock + + {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) + %Tesla.Env{status: status, body: body} + end) + end +end diff --git a/test/support/organizations_client_mock.ex b/test/support/organizations_client_mock.ex new file mode 100644 index 0000000..71a5cc1 --- /dev/null +++ b/test/support/organizations_client_mock.ex @@ -0,0 +1,186 @@ +defmodule WorkOS.Organizations.ClientMock do + @moduledoc false + + import ExUnit.Assertions, only: [assert: 1] + + def list_organizations(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :get + assert request.url == "#{WorkOS.base_url()}/organizations" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + success_body = %{ + "data" => [ + %{ + "id" => "org_01EHT88Z8J8795GZNQ4ZP1J81T", + "object" => "organization", + "name" => "Test Organization 1", + "allow_profiles_outside_organization" => false, + "domains" => [ + %{ + "domain" => "example.com", + "object" => "organization_domain", + "id" => "org_domain_01EHT88Z8WZEFWYPM6EC9BX2R8" + } + ], + "created_at" => "2023-07-17T20:07:20.055Z", + "updated_at" => "2023-07-17T20:07:20.055Z" + }, + %{ + "id" => "org_01EGPJWMT2EQMK7FMPR3TBC861", + "object" => "organization", + "name" => "Test Organization 2", + "allow_profiles_outside_organization" => true, + "domains" => [ + %{ + "domain" => "workos.com", + "object" => "organization_domain", + "id" => "org_domain_01EGPJWMTHRB5FP6MKE14RZ9BQ" + } + ], + "created_at" => "2023-07-17T20:07:20.055Z", + "updated_at" => "2023-07-17T20:07:20.055Z" + } + ], + "list_metadata" => %{ + "before" => "before-id", + "after" => "after-id" + } + } + + {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) + %Tesla.Env{status: status, body: body} + end) + end + + def delete_organization(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + organization_id = opts |> Keyword.get(:assert_fields) |> Keyword.get(:organization_id) + assert request.method == :delete + assert request.url == "#{WorkOS.base_url()}/organizations/#{organization_id}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + {status, body} = Keyword.get(opts, :respond_with, {204, %{}}) + %Tesla.Env{status: status, body: body} + end) + end + + def get_organization(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + organization_id = opts |> Keyword.get(:assert_fields) |> Keyword.get(:organization_id) + assert request.method == :get + assert request.url == "#{WorkOS.base_url()}/organizations/#{organization_id}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + success_body = %{ + "id" => "org_01EHT88Z8J8795GZNQ4ZP1J81T", + "object" => "organization", + "name" => "Test Organization", + "allow_profiles_outside_organization" => false, + "domains" => [ + %{ + "domain" => "example.com", + "object" => "organization_domain", + "id" => "org_domain_01EHT88Z8WZEFWYPM6EC9BX2R8" + } + ], + "created_at" => "2023-07-17T20:07:20.055Z", + "updated_at" => "2023-07-17T20:07:20.055Z" + } + + {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) + %Tesla.Env{status: status, body: body} + end) + end + + def create_organization(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :post + assert request.url == "#{WorkOS.base_url()}/organizations" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + assert Enum.find(request.headers, fn {header, _} -> header == "Idempotency-Key" end) != nil + + body = Jason.decode!(request.body) + + for {field, value} <- + Keyword.get(opts, :assert_fields, []) |> Keyword.delete(:idempotency_key) do + assert body[to_string(field)] == value + end + + success_body = %{ + "id" => "org_01EHT88Z8J8795GZNQ4ZP1J81T", + "object" => "organization", + "name" => body[:name], + "allow_profiles_outside_organization" => false, + "domains" => [ + %{ + "domain" => "example.com", + "object" => "organization_domain", + "id" => "org_domain_01EHT88Z8WZEFWYPM6EC9BX2R8" + } + ], + "created_at" => "2023-07-17T20:07:20.055Z", + "updated_at" => "2023-07-17T20:07:20.055Z" + } + + {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) + %Tesla.Env{status: status, body: body} + end) + end + + def update_organization(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + organization_id = opts |> Keyword.get(:assert_fields) |> Keyword.get(:organization_id) + assert request.method == :put + assert request.url == "#{WorkOS.base_url()}/organizations/#{organization_id}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + body = Jason.decode!(request.body) + + for {field, value} <- + Keyword.get(opts, :assert_fields, []) |> Keyword.delete(:organization_id) do + assert body[to_string(field)] == value + end + + success_body = %{ + "id" => "org_01EHT88Z8J8795GZNQ4ZP1J81T", + "object" => "organization", + "name" => body[:name], + "allow_profiles_outside_organization" => false, + "domains" => [ + %{ + "domain" => "example.com", + "object" => "organization_domain", + "id" => "org_domain_01EHT88Z8WZEFWYPM6EC9BX2R8" + } + ], + "created_at" => "2023-07-17T20:07:20.055Z", + "updated_at" => "2023-07-17T20:07:20.055Z" + } + + {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) + %Tesla.Env{status: status, body: body} + end) + end +end diff --git a/test/support/passwordless_client_mock.ex b/test/support/passwordless_client_mock.ex new file mode 100644 index 0000000..95ce064 --- /dev/null +++ b/test/support/passwordless_client_mock.ex @@ -0,0 +1,62 @@ +defmodule WorkOS.Passwordless.ClientMock do + @moduledoc false + + import ExUnit.Assertions, only: [assert: 1] + + def create_session(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :post + assert request.url == "#{WorkOS.base_url()}/passwordless/sessions" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + body = Jason.decode!(request.body) + + for {field, value} <- + Keyword.get(opts, :assert_fields, []) do + assert body[to_string(field)] == value + end + + success_body = %{ + "id" => "passwordless_session_01EHDAK2BFGWCSZXP9HGZ3VK8C", + "email" => "passwordless_session_email@workos.com", + "expires_at" => "2020-08-13T05:50:00.000Z", + "link" => "https://auth.workos.com/passwordless/token/confirm", + "object" => "passwordless_session" + } + + {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) + %Tesla.Env{status: status, body: body} + end) + end + + def send_session(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + session_id = opts |> Keyword.get(:assert_fields) |> Keyword.get(:session_id) + assert request.method == :post + assert request.url == "#{WorkOS.base_url()}/passwordless/sessions/#{session_id}/send" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + body = Jason.decode!(request.body) + + for {field, value} <- + Keyword.get(opts, :assert_fields, []) |> Keyword.delete(:session_id) do + assert body[to_string(field)] == value + end + + success_body = %{ + "success" => true + } + + {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) + %Tesla.Env{status: status, body: body} + end) + end +end diff --git a/test/support/portal_client_mock.ex b/test/support/portal_client_mock.ex new file mode 100644 index 0000000..28bb434 --- /dev/null +++ b/test/support/portal_client_mock.ex @@ -0,0 +1,31 @@ +defmodule WorkOS.Portal.ClientMock do + @moduledoc false + + import ExUnit.Assertions, only: [assert: 1] + + def generate_link(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :post + assert request.url == "#{WorkOS.base_url()}/portal/generate_link" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + body = Jason.decode!(request.body) + + for {field, value} <- + Keyword.get(opts, :assert_fields, []) do + assert body[to_string(field)] == value + end + + success_body = %{ + "link" => "https://id.workos.com/portal/launch?secret=secret" + } + + {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) + %Tesla.Env{status: status, body: body} + end) + end +end diff --git a/test/support/sso_client_mock.ex b/test/support/sso_client_mock.ex new file mode 100644 index 0000000..ba4ac0f --- /dev/null +++ b/test/support/sso_client_mock.ex @@ -0,0 +1,158 @@ +defmodule WorkOS.SSO.ClientMock do + @moduledoc false + + import ExUnit.Assertions, only: [assert: 1] + + def get_profile_and_token(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :post + assert request.url == "#{WorkOS.base_url()}/sso/token" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + body = Jason.decode!(request.body) + + for {field, value} <- Keyword.get(opts, :assert_fields, []) do + assert body[to_string(field)] == value + end + + groups = + case Map.get(context, :with_group_attribute, []) do + true -> ["Admins", "Developers"] + _ -> [] + end + + success_body = %{ + "access_token" => "01DMEK0J53CVMC32CK5SE0KZ8Q", + "profile" => %{ + "id" => "prof_123", + "idp_i" => "123", + "organization_id" => "org_123", + "connection_id" => "conn_123", + "connection_type" => "OktaSAML", + "email" => "foo@test.com", + "first_name" => "foo", + "last_name" => "bar", + "groups" => groups, + "raw_attributes" => %{ + "email" => "foo@test.com", + "first_name" => "foo", + "last_name" => "bar", + "groups" => groups + } + } + } + + {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) + %Tesla.Env{status: status, body: body} + end) + end + + def get_profile(_context, opts \\ []) do + Tesla.Mock.mock(fn request -> + access_token = Keyword.get(opts, :assert_fields, []) |> Keyword.get(:access_token) + + assert request.method == :get + assert request.url == "#{WorkOS.base_url()}/sso/profile" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{access_token}"} + + success_body = %{ + "id" => "prof_123", + "idp_i" => "123", + "organization_id" => "org_123", + "connection_id" => "conn_123", + "connection_type" => "OktaSAML", + "email" => "foo@test.com", + "first_name" => "foo", + "last_name" => "bar", + "raw_attributes" => %{ + "email" => "foo@test.com", + "first_name" => "foo", + "last_name" => "bar" + } + } + + {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) + %Tesla.Env{status: status, body: body} + end) + end + + def get_connection(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + connection_id = opts |> Keyword.get(:assert_fields) |> Keyword.get(:connection_id) + assert request.method == :get + assert request.url == "#{WorkOS.base_url()}/connections/#{connection_id}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + success_body = %{ + "id" => connection_id, + "organization_id" => "org_123", + "name" => "Connection", + "connection_type" => "OktaSAML", + "state" => "active", + "domains" => [], + "created_at" => "2023-07-17T20:07:20.055Z", + "updated_at" => "2023-07-17T20:07:20.055Z" + } + + {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) + %Tesla.Env{status: status, body: body} + end) + end + + def list_connections(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :get + assert request.url == "#{WorkOS.base_url()}/connections" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + success_body = %{ + "data" => [ + %{ + "id" => "conn_123", + "organization_id" => "org_123", + "name" => "Connection", + "connection_type" => "OktaSAML", + "state" => "active", + "domains" => [], + "created_at" => "2023-07-17T20:07:20.055Z", + "updated_at" => "2023-07-17T20:07:20.055Z" + } + ], + "list_metadata" => %{} + } + + {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) + %Tesla.Env{status: status, body: body} + end) + end + + def delete_connection(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + connection_id = opts |> Keyword.get(:assert_fields) |> Keyword.get(:connection_id) + assert request.method == :delete + assert request.url == "#{WorkOS.base_url()}/connections/#{connection_id}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + {status, body} = Keyword.get(opts, :respond_with, {204, %{}}) + %Tesla.Env{status: status, body: body} + end) + end +end diff --git a/test/support/test_case.ex b/test/support/test_case.ex new file mode 100644 index 0000000..35d1227 --- /dev/null +++ b/test/support/test_case.ex @@ -0,0 +1,17 @@ +defmodule WorkOS.TestCase do + @moduledoc false + + defmacro __using__(_opts) do + quote do + use ExUnit.Case + import WorkOS.TestCase + end + end + + def setup_env(_context) do + %{ + api_key: System.get_env("WORKOS_API_KEY", "sk_example_123456789"), + client_id: System.get_env("WORKOS_CLIENT_ID", "client_123456789") + } + end +end diff --git a/test/support/user_management_client_mock.ex b/test/support/user_management_client_mock.ex new file mode 100644 index 0000000..036e5e4 --- /dev/null +++ b/test/support/user_management_client_mock.ex @@ -0,0 +1,553 @@ +defmodule WorkOS.UserManagement.ClientMock do + @moduledoc false + + import ExUnit.Assertions, only: [assert: 1] + + @user_mock %{ + "object" => "user", + "id" => "user_01H5JQDV7R7ATEYZDEG0W5PRYS", + "email" => "test@example.com", + "first_name" => "Test 01", + "last_name" => "User", + "created_at" => "2023-07-18T02:07:19.911Z", + "updated_at" => "2023-07-18T02:07:19.911Z", + "email_verified" => true + } + + @invitation_mock %{ + "object" => "invitation", + "id" => "invitation_01H5JQDV7R7ATEYZDEG0W5PRYS", + "email" => "test@workos.com", + "state" => "pending", + "accepted_at" => "2023-07-18T02:07:19.911Z", + "revoked_at" => "2023-07-18T02:07:19.911Z", + "expires_at" => "2023-07-18T02:07:19.911Z", + "organization_id" => "org_01H5JQDV7R7ATEYZDEG0W5PRYS", + "token" => "Z1uX3RbwcIl5fIGJJJCXXisdI", + "created_at" => "2023-07-18T02:07:19.911Z", + "updated_at" => "2023-07-18T02:07:19.911Z" + } + + @organization_membership_mock %{ + "object" => "organization_membership", + "id" => "om_01H5JQDV7R7ATEYZDEG0W5PRYS", + "user_id" => "user_01H5JQDV7R7ATEYZDEG0W5PRYS", + "organization_id" => "organization_01H5JQDV7R7ATEYZDEG0W5PRYS", + "created_at" => "2023-07-18T02:07:19.911Z", + "updated_at" => "2023-07-18T02:07:19.911Z" + } + + @authentication_factor_mock %{ + "object" => "authentication_factor", + "id" => "auth_factor_1234", + "created_at" => "2022-03-15T20:39:19.892Z", + "updated_at" => "2022-03-15T20:39:19.892Z", + "type" => "totp", + "totp" => %{ + "issuer" => "WorkOS", + "user" => "some_user" + } + } + + @authentication_challenge_mock %{ + "object" => "authentication_challenge", + "id" => "auth_challenge_1234", + "created_at" => "2022-03-15T20:39:19.892Z", + "updated_at" => "2022-03-15T20:39:19.892Z", + "expires_at" => "2022-03-15T21:39:19.892Z", + "code" => "12345", + "authentication_factor_id" => "auth_factor_1234" + } + + @authentication_mock %{ + "user" => @user_mock, + "organization_id" => "organization_01H5JQDV7R7ATEYZDEG0W5PRYS" + } + + def get_user(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + user_id = opts |> Keyword.get(:assert_fields) |> Keyword.get(:user_id) + assert request.method == :get + assert request.url == "#{WorkOS.base_url()}/user_management/users/#{user_id}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + success_body = @user_mock + + {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) + %Tesla.Env{status: status, body: body} + end) + end + + def list_users(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :get + assert request.url == "#{WorkOS.base_url()}/user_management/users" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + success_body = %{ + "data" => [ + @user_mock + ], + "list_metadata" => %{ + "before" => "before-id", + "after" => "after-id" + } + } + + {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) + %Tesla.Env{status: status, body: body} + end) + end + + def create_user(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :post + assert request.url == "#{WorkOS.base_url()}/user_management/users" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + body = Jason.decode!(request.body) + + for {field, value} <- + Keyword.get(opts, :assert_fields, []) do + assert body[to_string(field)] == value + end + + success_body = @user_mock + + {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) + %Tesla.Env{status: status, body: body} + end) + end + + def update_user(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + user_id = opts |> Keyword.get(:assert_fields) |> Keyword.get(:user_id) + assert request.method == :put + assert request.url == "#{WorkOS.base_url()}/user_management/users/#{user_id}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + body = Jason.decode!(request.body) + + for {field, value} <- Keyword.get(opts, :assert_fields, []) |> Keyword.delete(:user_id) do + assert body[to_string(field)] == value + end + + success_body = @user_mock + + {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) + %Tesla.Env{status: status, body: body} + end) + end + + def delete_user(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + user_id = opts |> Keyword.get(:assert_fields) |> Keyword.get(:user_id) + assert request.method == :delete + assert request.url == "#{WorkOS.base_url()}/user_management/users/#{user_id}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + {status, body} = Keyword.get(opts, :respond_with, {204, %{}}) + %Tesla.Env{status: status, body: body} + end) + end + + def authenticate(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :post + assert request.url == "#{WorkOS.base_url()}/user_management/authenticate" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + body = Jason.decode!(request.body) + + for {field, value} <- + Keyword.get(opts, :assert_fields, []) do + assert body[to_string(field)] == value + end + + success_body = @authentication_mock + + {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) + %Tesla.Env{status: status, body: body} + end) + end + + def send_magic_auth_code(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :post + assert request.url == "#{WorkOS.base_url()}/user_management/magic_auth/send" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + body = Jason.decode!(request.body) + + for {field, value} <- Keyword.get(opts, :assert_fields, []) do + assert body[to_string(field)] == value + end + + success_body = %{} + + {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) + %Tesla.Env{status: status, body: body} + end) + end + + def enroll_auth_factor(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + user_id = opts |> Keyword.get(:assert_fields) |> Keyword.get(:user_id) + assert request.method == :post + + assert request.url == + "#{WorkOS.base_url()}/user_management/users/#{user_id}/auth_factors" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + body = Jason.decode!(request.body) + + for {field, value} <- Keyword.get(opts, :assert_fields, []) |> Keyword.delete(:user_id) do + assert body[to_string(field)] == value + end + + success_body = %{ + "challenge" => @authentication_challenge_mock, + "factor" => @authentication_factor_mock + } + + {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) + %Tesla.Env{status: status, body: body} + end) + end + + def send_verification_email(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + user_id = opts |> Keyword.get(:assert_fields) |> Keyword.get(:user_id) + assert request.method == :post + + assert request.url == + "#{WorkOS.base_url()}/user_management/users/#{user_id}/email_verification/send" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + success_body = %{ + "user" => @user_mock + } + + {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) + %Tesla.Env{status: status, body: body} + end) + end + + def list_auth_factors(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + user_id = opts |> Keyword.get(:assert_fields) |> Keyword.get(:user_id) + assert request.method == :get + assert request.url == "#{WorkOS.base_url()}/user_management/users/#{user_id}/auth_factors" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + success_body = %{ + "data" => [ + @authentication_factor_mock + ], + "list_metadata" => %{ + "before" => "before-id", + "after" => "after-id" + } + } + + {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) + %Tesla.Env{status: status, body: body} + end) + end + + def verify_email(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + user_id = opts |> Keyword.get(:assert_fields) |> Keyword.get(:user_id) + assert request.method == :post + + assert request.url == + "#{WorkOS.base_url()}/user_management/users/#{user_id}/email_verification/confirm" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + body = Jason.decode!(request.body) + + for {field, value} <- Keyword.get(opts, :assert_fields, []) |> Keyword.delete(:user_id) do + assert body[to_string(field)] == value + end + + success_body = %{"user" => @user_mock} + + {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) + %Tesla.Env{status: status, body: body} + end) + end + + def send_password_reset_email(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :post + assert request.url == "#{WorkOS.base_url()}/user_management/password_reset/send" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + body = Jason.decode!(request.body) + + for {field, value} <- + Keyword.get(opts, :assert_fields, []) do + assert body[to_string(field)] == value + end + + success_body = %{ + user: @user_mock + } + + {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) + %Tesla.Env{status: status, body: body} + end) + end + + def reset_password(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :post + assert request.url == "#{WorkOS.base_url()}/user_management/password_reset/confirm" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + body = Jason.decode!(request.body) + + for {field, value} <- + Keyword.get(opts, :assert_fields, []) do + assert body[to_string(field)] == value + end + + success_body = %{ + "user" => @user_mock + } + + {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) + %Tesla.Env{status: status, body: body} + end) + end + + def get_organization_membership(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + organization_membership_id = + opts |> Keyword.get(:assert_fields) |> Keyword.get(:organization_membership_id) + + assert request.method == :get + + assert request.url == + "#{WorkOS.base_url()}/user_management/organization_memberships/#{organization_membership_id}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + success_body = @organization_membership_mock + + {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) + %Tesla.Env{status: status, body: body} + end) + end + + def list_organization_memberships(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :get + assert request.url == "#{WorkOS.base_url()}/user_management/organization_memberships" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + success_body = %{ + "data" => [ + @organization_membership_mock + ], + "list_metadata" => %{ + "before" => "before-id", + "after" => "after-id" + } + } + + {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) + %Tesla.Env{status: status, body: body} + end) + end + + def create_organization_membership(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :post + assert request.url == "#{WorkOS.base_url()}/user_management/organization_memberships" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + body = Jason.decode!(request.body) + + for {field, value} <- + Keyword.get(opts, :assert_fields, []) do + assert body[to_string(field)] == value + end + + success_body = @organization_membership_mock + + {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) + %Tesla.Env{status: status, body: body} + end) + end + + def delete_organization_membership(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + organization_membership_id = + opts |> Keyword.get(:assert_fields) |> Keyword.get(:organization_membership_id) + + assert request.method == :delete + + assert request.url == + "#{WorkOS.base_url()}/user_management/organization_memberships/#{organization_membership_id}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + {status, body} = Keyword.get(opts, :respond_with, {204, %{}}) + %Tesla.Env{status: status, body: body} + end) + end + + def get_invitation(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + invitation_id = opts |> Keyword.get(:assert_fields) |> Keyword.get(:invitation_id) + assert request.method == :get + assert request.url == "#{WorkOS.base_url()}/user_management/invitations/#{invitation_id}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + success_body = @invitation_mock + + {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) + %Tesla.Env{status: status, body: body} + end) + end + + def list_invitations(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :get + assert request.url == "#{WorkOS.base_url()}/user_management/invitations" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + success_body = %{ + "data" => [ + @invitation_mock + ], + "list_metadata" => %{ + "before" => "before-id", + "after" => "after-id" + } + } + + {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) + %Tesla.Env{status: status, body: body} + end) + end + + def send_invitation(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :post + assert request.url == "#{WorkOS.base_url()}/user_management/invitations" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + body = Jason.decode!(request.body) + + for {field, value} <- + Keyword.get(opts, :assert_fields, []) do + assert body[to_string(field)] == value + end + + success_body = @invitation_mock + + {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) + %Tesla.Env{status: status, body: body} + end) + end + + def revoke_invitation(context, opts \\ []) do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + invitation_id = opts |> Keyword.get(:assert_fields) |> Keyword.get(:invitation_id) + assert request.method == :post + + assert request.url == + "#{WorkOS.base_url()}/user_management/invitations/#{invitation_id}/revoke" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + success_body = @invitation_mock + + {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) + %Tesla.Env{status: status, body: body} + end) + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs index 9f1d507..869559e 100755 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,5 +1 @@ ExUnit.start() - -Application.put_env(:workos, :adapter, Tesla.Mock) -Application.put_env(:workos, :api_key, "sk_TEST") -Application.put_env(:workos, :client_id, "project_TEST") diff --git a/test/workos/api_test.exs b/test/workos/api_test.exs deleted file mode 100644 index a6ef77a..0000000 --- a/test/workos/api_test.exs +++ /dev/null @@ -1,107 +0,0 @@ -defmodule WorkOS.ApiTest do - use ExUnit.Case - doctest WorkOS.SSO - import Tesla.Mock - - alias WorkOS.API - - setup do - mock(fn - %{method: :get, url: "https://api.workos.com/test"} -> - %Tesla.Env{status: 200, body: "hello"} - - %{method: :post, url: "https://api.workos.com/test"} -> - %Tesla.Env{status: 200, body: "hello"} - - %{method: :put, url: "https://api.workos.com/test"} -> - %Tesla.Env{status: 200, body: "hello"} - - %{method: :delete, url: "https://api.workos.com/test"} -> - %Tesla.Env{status: 200, body: "hello"} - end) - - :ok - end - - describe "#get/3" do - test "returns a response object" do - assert {:ok, "hello"} = API.get("/test") - end - end - - describe "#post/3" do - test "returns a response object" do - assert {:ok, "hello"} = API.post("/test") - end - end - - describe "#put/3" do - test "returns a response object" do - assert {:ok, "hello"} = API.put("/test") - end - end - - describe "#delete/3" do - test "returns a response object" do - assert {:ok, "hello"} = API.delete("/test") - end - end - - describe "#handle_response/1" do - test "returns an okay when status is <400" do - assert API.handle_response({:ok, %{status: 200, body: "OKAY"}}) == {:ok, "OKAY"} - end - - test "returns an error when status is >=400" do - assert API.handle_response({:ok, %{status: 400, body: "BAD"}}) == {:error, "BAD"} - end - - test "returns an error when request errors" do - assert API.handle_response({:error, "BAD"}) == {:error, "BAD"} - end - end - - describe "#process_response/1" do - test "removes message fluff and returns just the message" do - assert %{body: %{"message" => "test"}} - |> API.process_response() == - "test" - end - - test "removes data fluff and returns just the data" do - assert %{body: %{"data" => ["test"]}} - |> API.process_response() == - ["test"] - end - - test "returns the response body otherwise" do - assert %{body: %{"type" => "test"}} - |> API.process_response() == - %{"type" => "test"} - end - - test "returns the raw argument when a body isn't defined" do - assert API.process_response("test") == "test" - end - end - - describe "#process_params/3" do - test "only takes allowed params" do - assert %{test: 1, blocked: 1} - |> API.process_params([:test]) == - %{test: 1} - end - - test "merges default params" do - assert %{test: 1} - |> API.process_params([:test], %{merged: 1}) == - %{test: 1, merged: 1} - end - - test "overrides default params" do - assert %{test: 1, overrides: 1} - |> API.process_params([:test], %{overrides: 0}) == - %{test: 1, overrides: 1} - end - end -end diff --git a/test/workos/audit_logs/audit_logs_test.exs b/test/workos/audit_logs/audit_logs_test.exs deleted file mode 100644 index c7188bb..0000000 --- a/test/workos/audit_logs/audit_logs_test.exs +++ /dev/null @@ -1,177 +0,0 @@ -defmodule WorkOS.AuditLogsTest do - use ExUnit.Case - doctest WorkOS.AuditLogs - import Tesla.Mock - - setup do - mock(fn - %{method: :post, url: "https://api.workos.com/audit_logs/events"} -> - %Tesla.Env{status: 201} - - %{method: :post, url: "https://api.workos.com/audit_logs/exports"} -> - %Tesla.Env{ - status: 201, - body: %{ - object: "audit_log_export", - id: "audit_log_export_01GBZK5MP7TD1YCFQHFR22180V", - state: "ready", - url: "https://exports.audit-logs.com/audit-log-exports/export.csv", - created_at: "2022-09-02T17:14:57.094Z", - updated_at: "2022-09-02T17:14:57.094Z" - } - } - - %{ - method: :get, - url: - "https://api.workos.com/audit_logs/exports/audit_log_export_01GBZK5MP7TD1YCFQHFR22180V" - } -> - %Tesla.Env{ - status: 200, - body: %{ - object: "audit_log_export", - id: "audit_log_export_01GBZK5MP7TD1YCFQHFR22180V", - state: "ready", - url: "https://exports.audit-logs.com/audit-log-exports/export.csv", - created_at: "2022-09-02T17:14:57.094Z", - updated_at: "2022-09-02T17:14:57.094Z" - } - } - end) - - :ok - end - - describe "#create_event/1 with a valid event" do - test "returns a 201 status" do - assert {:ok, nil} = - WorkOS.AuditLogs.create_event(%{ - organization: "org_123", - event: %{ - action: "user.signed_in", - occurred_at: "2022-09-08T19:46:03.435Z", - version: 1, - actor: %{ - id: "user_TF4C5938", - type: "user", - name: "Jon Smith", - metadata: %{ - role: "admin" - } - }, - targets: [ - %{ - id: "user_98432YHF", - type: "user", - name: "Jon Smith" - }, - %{ - id: "team_J8YASKA2", - type: "team", - metadata: %{ - owner: "user_01GBTCQ2" - } - } - ], - context: %{ - location: "1.1.1.1", - user_agent: "Chrome/104.0.0" - }, - metadata: %{ - extra: "data" - } - } - }) - end - end - - describe "@create_event/0 missing params" do - assert_raise ArgumentError, fn -> - WorkOS.AuditLogs.create_event(%{}) - end - end - - describe "@create_export/1 with valid params" do - test "returns a 201 status" do - assert {:ok, - %{ - object: "audit_log_export", - id: "audit_log_export_01GBZK5MP7TD1YCFQHFR22180V", - state: "ready", - url: "https://exports.audit-logs.com/audit-log-exports/export.csv", - created_at: "2022-09-02T17:14:57.094Z", - updated_at: "2022-09-02T17:14:57.094Z" - }} = - WorkOS.AuditLogs.create_export(%{ - organization: "org_123", - range_start: "2022-09-08T19:46:03.435Z", - range_end: "2022-09-08T19:46:03.435Z", - actions: ["user.signed_in"], - actors: ["user_TF4C5938"], - targets: ["user_98432YHF"] - }) - end - end - - describe "@create_export/1 missing params" do - assert_raise ArgumentError, fn -> - WorkOS.AuditLogs.create_export(%{}) - end - end - - describe "@create_export/1 missing organization param" do - assert_raise ArgumentError, fn -> - WorkOS.AuditLogs.create_export(%{ - range_start: "2022-09-08T19:46:03.435Z", - range_end: "2022-09-08T19:46:03.435Z", - actions: ["user.signed_in"], - actors: ["user_TF4C5938"], - targets: ["user_98432YHF"] - }) - end - end - - describe "@create_export/1 missing range_start param" do - assert_raise ArgumentError, fn -> - WorkOS.AuditLogs.create_export(%{ - organization: "org_123", - range_end: "2022-09-08T19:46:03.435Z", - actions: ["user.signed_in"], - actors: ["user_TF4C5938"], - targets: ["user_98432YHF"] - }) - end - end - - describe "@create_export/1 missing range_end param" do - assert_raise ArgumentError, fn -> - WorkOS.AuditLogs.create_export(%{ - organization: "org_123", - range_start: "2022-09-08T19:46:03.435Z", - actions: ["user.signed_in"], - actors: ["user_TF4C5938"], - targets: ["user_98432YHF"] - }) - end - end - - describe "@get_export/1" do - test "returns a 200 status" do - assert {:ok, - %{ - object: "audit_log_export", - id: "audit_log_export_01GBZK5MP7TD1YCFQHFR22180V", - state: "ready", - url: "https://exports.audit-logs.com/audit-log-exports/export.csv", - created_at: "2022-09-02T17:14:57.094Z", - updated_at: "2022-09-02T17:14:57.094Z" - }} = WorkOS.AuditLogs.get_export("audit_log_export_01GBZK5MP7TD1YCFQHFR22180V") - end - end - - describe "@get_export/1 missing id" do - assert_raise ArgumentError, fn -> - WorkOS.AuditLogs.get_export(nil) - end - end -end diff --git a/test/workos/audit_logs_test.exs b/test/workos/audit_logs_test.exs new file mode 100644 index 0000000..54d39df --- /dev/null +++ b/test/workos/audit_logs_test.exs @@ -0,0 +1,87 @@ +defmodule WorkOS.AuditLogsTest do + use WorkOS.TestCase + + alias WorkOS.AuditLogs.ClientMock + + setup :setup_env + + @event_mock %{ + "action" => "document.updated", + "occurred_at" => "2022-09-08T19:46:03.435Z", + "actor" => %{ + "id" => "user_1", + "name" => "Jon Smith", + "type" => "user" + }, + "targets" => [ + %{ + "id" => "document_39127", + "type" => "document" + } + ], + "context" => %{ + "location" => "192.0.0.8", + "user_agent" => "Firefox" + }, + "metadata" => %{ + "successful" => true + } + } + + describe "create_event" do + test "with an idempotency key, includes an idempotency key with request", context do + opts = [ + organization_id: "org_123", + event: @event_mock, + idempotency_key: "the-idempotency-key" + ] + + context |> ClientMock.create_event(assert_fields: opts) + + assert {:ok, %WorkOS.Empty{}} = WorkOS.AuditLogs.create_event(opts |> Enum.into(%{})) + end + + test "with a valid payload, creates an event", context do + opts = [ + organization_id: "org_123", + event: @event_mock + ] + + context |> ClientMock.create_event(assert_fields: opts) + + assert {:ok, %WorkOS.Empty{}} = WorkOS.AuditLogs.create_event(opts |> Enum.into(%{})) + end + end + + describe "create_export" do + test "with valid payload, creates an audit log export", context do + opts = [ + organization_id: "org_01EHWNCE74X7JSDV0X3SZ3KJNY", + range_start: "2022-06-22T15:04:19.704Z", + range_end: "2022-08-22T15:04:19.704Z", + actions: ["user.signed_in"], + actors: ["Jon Smith"], + targets: ["team"] + ] + + context + |> ClientMock.create_export(assert_fields: opts) + + assert {:ok, %WorkOS.AuditLogs.Export{}} = + opts |> Map.new() |> WorkOS.AuditLogs.create_export() + end + end + + describe "get_export" do + test "requests an audit log export", context do + opts = [audit_log_export_id: "audit_log_export_1234"] + + context |> ClientMock.get_export(assert_fields: opts) + + assert {:ok, %WorkOS.AuditLogs.Export{id: id}} = + WorkOS.AuditLogs.get_export(opts |> Keyword.get(:audit_log_export_id)) + + refute is_nil(id) + end + end +end diff --git a/test/workos/directory_sync/directory_sync_test.exs b/test/workos/directory_sync/directory_sync_test.exs deleted file mode 100644 index a0b73ea..0000000 --- a/test/workos/directory_sync/directory_sync_test.exs +++ /dev/null @@ -1,35 +0,0 @@ -defmodule WorkOS.DirectorySyncTest do - use ExUnit.Case - doctest WorkOS.DirectorySync - import Tesla.Mock - - describe "#delete_directory/1 with a valid directory id" do - setup do - mock(fn - %{method: :delete, url: "https://api.workos.com/directories/directory_12345"} -> - %Tesla.Env{status: 202, body: "Success"} - end) - - :ok - end - - test "returns a 202 status" do - assert {:ok, "Success"} = WorkOS.DirectorySync.delete_directory('directory_12345') - end - end - - describe "#delete_directory/1 with an invalid directory id" do - setup do - mock(fn - %{method: :delete, url: "https://api.workos.com/directories/invalid"} -> - %Tesla.Env{status: 404, body: "Not Found"} - end) - - :ok - end - - test "returns a 404 status" do - assert {:error, "Not Found"} = WorkOS.DirectorySync.delete_directory('invalid') - end - end -end diff --git a/test/workos/directory_sync_test.exs b/test/workos/directory_sync_test.exs new file mode 100644 index 0000000..4083459 --- /dev/null +++ b/test/workos/directory_sync_test.exs @@ -0,0 +1,110 @@ +defmodule WorkOS.DirectorySyncTest do + use WorkOS.TestCase + + alias WorkOS.DirectorySync.ClientMock + + setup :setup_env + + describe "get_directory" do + test "requests a directory", context do + opts = [directory_id: "directory_123"] + + context |> ClientMock.get_directory(assert_fields: opts) + + assert {:ok, %WorkOS.DirectorySync.Directory{id: id}} = + WorkOS.DirectorySync.get_directory(opts |> Keyword.get(:directory_id)) + + refute is_nil(id) + end + end + + describe "list_directories" do + test "requests directories with options", context do + opts = [organization_id: "org_1234"] + + context |> ClientMock.list_directories(assert_fields: opts) + + assert {:ok, %WorkOS.List{data: [%WorkOS.DirectorySync.Directory{}], list_metadata: %{}}} = + WorkOS.DirectorySync.list_directories(opts |> Enum.into(%{})) + end + end + + describe "delete_directory" do + test "sends a request to delete a directory", context do + opts = [directory_id: "conn_123"] + + context |> ClientMock.delete_directory(assert_fields: opts) + + assert {:ok, %WorkOS.Empty{}} = + WorkOS.DirectorySync.delete_directory(opts |> Keyword.get(:directory_id)) + end + end + + describe "get_user" do + test "requests a directory user", context do + opts = [directory_user_id: "dir_usr_123"] + + context |> ClientMock.get_user(assert_fields: opts) + + assert {:ok, %WorkOS.DirectorySync.Directory.User{id: id}} = + WorkOS.DirectorySync.get_user(opts |> Keyword.get(:directory_user_id)) + + refute is_nil(id) + end + end + + describe "list_users" do + test "requests directory users with options", context do + opts = [directory: "directory_123"] + + context |> ClientMock.list_users(assert_fields: opts) + + assert {:ok, + %WorkOS.List{ + data: [%WorkOS.DirectorySync.Directory.User{custom_attributes: custom_attributes}], + list_metadata: %{} + }} = WorkOS.DirectorySync.list_users(opts |> Enum.into(%{})) + + assert %{"custom" => true} = custom_attributes + end + end + + describe "get_group" do + test "requests a directory group", context do + opts = [directory_group_id: "dir_grp_123"] + + context |> ClientMock.get_group(assert_fields: opts) + + assert {:ok, %WorkOS.DirectorySync.Directory.Group{id: id}} = + WorkOS.DirectorySync.get_group(opts |> Keyword.get(:directory_group_id)) + + refute is_nil(id) + end + end + + describe "list_groups" do + test "requests directory groups with `directory` option", context do + opts = [directory: "directory_123"] + + context |> ClientMock.list_groups(assert_fields: opts) + + assert {:ok, + %WorkOS.List{ + data: [%WorkOS.DirectorySync.Directory.Group{}], + list_metadata: %{} + }} = WorkOS.DirectorySync.list_groups(opts |> Enum.into(%{})) + end + + test "requests directory groups with `user` option", context do + opts = [user: "directory_usr_123"] + + context |> ClientMock.list_groups(assert_fields: opts) + + assert {:ok, + %WorkOS.List{ + data: [%WorkOS.DirectorySync.Directory.Group{}], + list_metadata: %{} + }} = WorkOS.DirectorySync.list_groups(opts |> Enum.into(%{})) + end + end +end diff --git a/test/workos/events_test.exs b/test/workos/events_test.exs new file mode 100644 index 0000000..38e3d42 --- /dev/null +++ b/test/workos/events_test.exs @@ -0,0 +1,18 @@ +defmodule WorkOS.EventsTest do + use WorkOS.TestCase + + alias WorkOS.Events.ClientMock + + setup :setup_env + + describe "list_events" do + test "requests events", context do + opts = [events: ["connection.activated"]] + + context |> ClientMock.list_events(assert_fields: opts) + + assert {:ok, %WorkOS.List{data: [%WorkOS.Events.Event{}], list_metadata: %{}}} = + WorkOS.Events.list_events(opts |> Enum.into(%{})) + end + end +end diff --git a/test/workos/mfa/mfa_test.exs b/test/workos/mfa/mfa_test.exs deleted file mode 100644 index 56dd631..0000000 --- a/test/workos/mfa/mfa_test.exs +++ /dev/null @@ -1,300 +0,0 @@ -defmodule WorkOS.MFATest do - use ExUnit.Case - doctest WorkOS.MFA - import Tesla.Mock - - setup do - mock(fn - %{ - method: :post, - url: "https://api.workos.com/auth/factors/enroll", - body: "{\"phone_number\":\"+15555555555\",\"type\":\"sms\"}" - } -> - %Tesla.Env{ - status: 201, - body: %{ - object: "authentication_factor", - id: "auth_factor_01FVYZ5QM8N98T9ME5BCB2BBMJ", - created_at: "2022-02-15T15:14:19.392Z", - updated_at: "2022-02-15T15:14:19.392Z", - type: "sms", - sms: %{ - phone_number: "+15555555555" - } - } - } - - %{ - method: :post, - url: "https://api.workos.com/auth/factors/enroll", - body: "{\"totp_issuer\":\"Foo Corp\",\"totp_user\":\"user_01GBTCQ2\",\"type\":\"totp\"}" - } -> - %Tesla.Env{ - status: 201, - body: %{ - object: "authentication_factor", - id: "auth_factor_01FVYZ5QM8N98T9ME5BCB2BBMJ", - created_at: "2022-02-15T15:14:19.392Z", - updated_at: "2022-02-15T15:14:19.392Z", - type: "totp", - totp: %{ - qr_code: "data:image/png;base64,{base64EncodedPng}", - secret: "NAGCCFS3EYRB422HNAKAKY3XDUORMSRF", - uri: "otpauth://totp/FooCorp:user_01GB" - } - } - } - - %{ - method: :post, - url: - "https://api.workos.com/auth/factors/auth_factor_01FVYZ5QM8N98T9ME5BCB2BBMJ/challenge", - body: "{\"authentication_factor_id\":\"auth_factor_01FVYZ5QM8N98T9ME5BCB2BBMJ\"}" - } -> - %Tesla.Env{ - status: 201, - body: %{ - object: "authentication_factor_challenge", - id: "auth_factor_challenge_01FVYZ5QM8N98T9ME5BCB2BBMJ", - created_at: "2022-02-15T15:26:53.274Z", - updated_at: "2022-02-15T15:26:53.274Z", - expires_at: "2022-02-15T15:36:53.279Z", - authentication_factor_id: "auth_factor_01FVYZ5QM8N98T9ME5BCB2BBMJ" - } - } - - %{ - method: :post, - url: - "https://api.workos.com/auth/factors/auth_factor_01FVYZ5QM8N98T9ME5BCB2BBMJ/challenge", - body: - "{\"authentication_factor_id\":\"auth_factor_01FVYZ5QM8N98T9ME5BCB2BBMJ\",\"sms_template\":\"Your Foo Corp one-time code is {{code}}\"}" - } -> - %Tesla.Env{ - status: 201, - body: %{ - object: "authentication_factor_challenge", - id: "auth_factor_challenge_01FVYZ5QM8N98T9ME5BCB2BBMJ", - created_at: "2022-02-15T15:26:53.274Z", - updated_at: "2022-02-15T15:26:53.274Z", - expires_at: "2022-02-15T15:36:53.279Z", - authentication_factor_id: "auth_factor_01FVYZ5QM8N98T9ME5BCB2BBMJ" - } - } - - %{ - method: :post, - url: - "https://api.workos.com/auth/challeneges/auth_factor_01FVYZ5QM8N98T9ME5BCB2BBMJ/verify", - body: - "{\"authentication_factor_id\":\"auth_factor_01FVYZ5QM8N98T9ME5BCB2BBMJ\",\"code\":\"123456\"}" - } -> - %Tesla.Env{ - status: 201, - body: %{ - challenge: %{ - object: "authentication_factor_challenge", - id: "auth_factor_challenge_01FVYZ5QM8N98T9ME5BCB2BBMJ", - created_at: "2022-02-15T15:26:53.274Z", - updated_at: "2022-02-15T15:26:53.274Z", - expires_at: "2022-02-15T15:36:53.279Z", - authentication_factor_id: "auth_factor_01FVYZ5QM8N98T9ME5BCB2BBMJ" - }, - valid: true - } - } - - %{ - method: :get, - url: - "https://api.workos.com/auth/factors/auth_factor_challenge_01FVYZ5QM8N98T9ME5BCB2BBMJ" - } -> - %Tesla.Env{ - status: 200, - body: %{ - object: "authentication_factor", - id: "auth_factor_01FVYZ5QM8N98T9ME5BCB2BBMJ", - created_at: "2022-02-15T15:14:19.392Z", - updated_at: "2022-02-15T15:14:19.392Z", - type: "totp", - totp: %{ - qr_code: "data:image/png;base64,{base64EncodedPng}", - secret: "NAGCCFS3EYRB422HNAKAKY3XDUORMSRF", - uri: - "otpauth://totp/FooCorp:alan.turing@foo-corp.com?secret=NAGCCFS3EYRB422HNAKAKY3XDUORMSRF&issuer=FooCorp" - } - } - } - - %{ - method: :delete, - url: "https://api.workos.com/auth/factors/auth_factor_01FVYZ5QM8N98T9ME5BCB2BBMJ" - } -> - %Tesla.Env{ - status: 204, - body: "" - } - end) - end - - describe "#enroll_factor/1 with type sms and valid params" do - test "returns a valid response" do - assert {:ok, - %{ - object: "authentication_factor", - id: "auth_factor_01FVYZ5QM8N98T9ME5BCB2BBMJ", - created_at: "2022-02-15T15:14:19.392Z", - updated_at: "2022-02-15T15:14:19.392Z", - type: "sms", - sms: %{ - phone_number: "+15555555555" - } - }} = - WorkOS.MFA.enroll_factor(%{ - type: "sms", - phone_number: "+15555555555" - }) - end - end - - describe "#enroll_factor/1 with type totp and valid params" do - test "returns a valid response" do - assert {:ok, - %{ - object: "authentication_factor", - id: "auth_factor_01FVYZ5QM8N98T9ME5BCB2BBMJ", - created_at: "2022-02-15T15:14:19.392Z", - updated_at: "2022-02-15T15:14:19.392Z", - type: "totp", - totp: %{ - qr_code: "data:image/png;base64,{base64EncodedPng}", - secret: "NAGCCFS3EYRB422HNAKAKY3XDUORMSRF", - uri: "otpauth://totp/FooCorp:user_01GB" - } - }} = - WorkOS.MFA.enroll_factor(%{ - type: "totp", - totp_issuer: "Foo Corp", - totp_user: "user_01GBTCQ2" - }) - end - end - - describe "#enroll_factor/1 with invalid type" do - test "raises an ArgumentError" do - assert_raise ArgumentError, fn -> - WorkOS.MFA.enroll_factor(%{type: "invalid"}) - end - end - end - - describe "#enroll_factor/1 with type sms when phone_number is missing" do - test "raises an ArgumentError" do - assert_raise ArgumentError, fn -> - WorkOS.MFA.enroll_factor(%{type: "sms"}) - end - end - end - - describe "#enroll_factor/1 with type totp when totp_issuer is missing" do - test "raises an ArgumentError" do - assert_raise ArgumentError, fn -> - WorkOS.MFA.enroll_factor(%{type: "totp", totp_user: "user_01GBTCQ2"}) - end - end - end - - describe "#enroll_factor/1 with type totp when totp_user is missing" do - test "raises an ArgumentError" do - assert_raise ArgumentError, fn -> - WorkOS.MFA.enroll_factor(%{type: "totp", totp_issuer: "Foo Corp"}) - end - end - end - - describe "#enroll_factor/1 with type totp when totp_issuer and totp_user are missing" do - test "raises an ArgumentError" do - assert_raise ArgumentError, fn -> - WorkOS.MFA.enroll_factor(%{type: "totp"}) - end - end - end - - describe "#chanllenge_factor/1 without sms_template" do - test "returns a valid response" do - assert {:ok, - %{ - object: "authentication_factor_challenge", - id: "auth_factor_challenge_01FVYZ5QM8N98T9ME5BCB2BBMJ", - created_at: "2022-02-15T15:26:53.274Z", - updated_at: "2022-02-15T15:26:53.274Z", - expires_at: "2022-02-15T15:36:53.279Z", - authentication_factor_id: "auth_factor_01FVYZ5QM8N98T9ME5BCB2BBMJ" - }} = - WorkOS.MFA.challenge_factor(%{ - authentication_factor_id: "auth_factor_01FVYZ5QM8N98T9ME5BCB2BBMJ" - }) - end - end - - describe "#challenge_factor/1 with sms_template" do - test "returns a valid response" do - assert {:ok, - %{ - object: "authentication_factor_challenge", - id: "auth_factor_challenge_01FVYZ5QM8N98T9ME5BCB2BBMJ", - created_at: "2022-02-15T15:26:53.274Z", - updated_at: "2022-02-15T15:26:53.274Z", - expires_at: "2022-02-15T15:36:53.279Z", - authentication_factor_id: "auth_factor_01FVYZ5QM8N98T9ME5BCB2BBMJ" - }} = - WorkOS.MFA.challenge_factor(%{ - authentication_factor_id: "auth_factor_01FVYZ5QM8N98T9ME5BCB2BBMJ", - sms_template: "Your Foo Corp one-time code is {{code}}" - }) - end - end - - describe "#verify_challenge/1 with missing authentication_factor_id" do - test "raises an ArgumentError" do - assert_raise ArgumentError, fn -> - WorkOS.MFA.verify_challenge(%{code: "123456"}) - end - end - end - - describe "#verify_challenge/1 with missing code" do - test "raises an ArgumentError" do - assert_raise ArgumentError, fn -> - WorkOS.MFA.verify_challenge(%{ - authentication_factor_id: "auth_factor_01FVYZ5QM8N98T9ME5BCB2BBMJ" - }) - end - end - end - - describe "#get_factor/1 with id" do - test "returns a valid response" do - assert {:ok, - %{ - object: "authentication_factor", - id: "auth_factor_01FVYZ5QM8N98T9ME5BCB2BBMJ", - created_at: "2022-02-15T15:14:19.392Z", - updated_at: "2022-02-15T15:14:19.392Z", - type: "totp", - totp: %{ - qr_code: "data:image/png;base64,{base64EncodedPng}", - secret: "NAGCCFS3EYRB422HNAKAKY3XDUORMSRF", - uri: - "otpauth://totp/FooCorp:alan.turing@foo-corp.com?secret=NAGCCFS3EYRB422HNAKAKY3XDUORMSRF&issuer=FooCorp" - } - }} = WorkOS.MFA.get_factor("auth_factor_challenge_01FVYZ5QM8N98T9ME5BCB2BBMJ") - end - end - - describe "#delete_factor/1" do - test "returns a valid response" do - assert {:ok, ""} = WorkOS.MFA.delete_factor("auth_factor_01FVYZ5QM8N98T9ME5BCB2BBMJ") - end - end -end diff --git a/test/workos/mfa_test.exs b/test/workos/mfa_test.exs new file mode 100644 index 0000000..537edc2 --- /dev/null +++ b/test/workos/mfa_test.exs @@ -0,0 +1,94 @@ +defmodule WorkOS.MFATest do + use WorkOS.TestCase + + alias WorkOS.MFA.ClientMock + + setup :setup_env + + describe "enroll_factor" do + test "with a valid payload, enrolls auth factor", context do + opts = [ + type: "totp" + ] + + context |> ClientMock.enroll_factor(assert_fields: opts) + + assert {:ok, + %WorkOS.MFA.AuthenticationFactor{ + id: id + }} = + WorkOS.MFA.enroll_factor(opts |> Enum.into(%{})) + + refute is_nil(id) + end + end + + describe "challenge_factor" do + test "with a valid payload, challenges factor", context do + opts = [ + authentication_factor_id: "auth_factor_1234", + sms_template: "Your Foo Corp one-time code is {{code}}" + ] + + context |> ClientMock.challenge_factor(assert_fields: opts) + + assert {:ok, + %WorkOS.MFA.AuthenticationChallenge{ + id: id, + authentication_factor_id: authentication_factor_id + }} = + WorkOS.MFA.challenge_factor(opts |> Enum.into(%{})) + + refute is_nil(id) + refute is_nil(authentication_factor_id) + end + end + + describe "verify_challenge" do + test "with a valid payload, verifies challenge", context do + opts = [ + authentication_challenge_id: "auth_factor_1234", + code: "Foo test" + ] + + context |> ClientMock.verify_challenge(assert_fields: opts) + + assert {:ok, + %WorkOS.MFA.VerifyChallenge{ + challenge: challenge, + valid: valid + }} = + WorkOS.MFA.verify_challenge( + opts |> Keyword.get(:authentication_challenge_id), + opts |> Enum.into(%{}) + ) + + refute is_nil(challenge["id"]) + assert valid == true + end + end + + describe "delete_factor" do + test "sends a request to delete a factor", context do + opts = [authentication_factor_id: "auth_factor_1234"] + + context |> ClientMock.delete_factor(assert_fields: opts) + + assert {:ok, %WorkOS.Empty{}} = + WorkOS.MFA.delete_factor(opts |> Keyword.get(:authentication_factor_id)) + end + end + + describe "get_factor" do + test "requests a factor", context do + opts = [authentication_factor_id: "auth_factor_1234"] + + context |> ClientMock.get_factor(assert_fields: opts) + + assert {:ok, %WorkOS.MFA.AuthenticationFactor{id: id}} = + WorkOS.MFA.get_factor(opts |> Keyword.get(:authentication_factor_id)) + + refute is_nil(id) + end + end +end diff --git a/test/workos/organization_domains_test.exs b/test/workos/organization_domains_test.exs new file mode 100644 index 0000000..f27089f --- /dev/null +++ b/test/workos/organization_domains_test.exs @@ -0,0 +1,52 @@ +defmodule WorkOS.OrganizationDomainsTest do + use WorkOS.TestCase + + alias WorkOS.OrganizationDomains.ClientMock + + setup :setup_env + + describe "get_organization_domain" do + test "requests an organization domain", context do + opts = [organization_domain_id: "org_domain_01HCZRAP3TPQ0X0DKJHR32TATG"] + + context |> ClientMock.get_organization_domain(assert_fields: opts) + + assert {:ok, %WorkOS.OrganizationDomains.OrganizationDomain{id: id}} = + WorkOS.OrganizationDomains.get_organization_domain( + opts + |> Keyword.get(:organization_domain_id) + ) + + refute is_nil(id) + end + end + + describe "create_organization_domain" do + test "with a valid payload, creates an organization domain", context do + opts = [organization_id: "org_01HCZRAP3TPQ0X0DKJHR32TATG", domain: "workos.com"] + + context |> ClientMock.create_organization_domain(assert_fields: opts) + + assert {:ok, %WorkOS.OrganizationDomains.OrganizationDomain{id: id}} = + WorkOS.OrganizationDomains.create_organization_domain(opts |> Enum.into(%{})) + + refute is_nil(id) + end + end + + describe "verify_organization_domain" do + test "verifies an organization domain", context do + opts = [organization_domain_id: "org_domain_01HCZRAP3TPQ0X0DKJHR32TATG"] + + context |> ClientMock.verify_organization_domain(assert_fields: opts) + + assert {:ok, %WorkOS.OrganizationDomains.OrganizationDomain{id: id}} = + WorkOS.OrganizationDomains.verify_organization_domain( + opts + |> Keyword.get(:organization_domain_id) + ) + + refute is_nil(id) + end + end +end diff --git a/test/workos/organizations/organizations_test.exs b/test/workos/organizations/organizations_test.exs deleted file mode 100644 index 09d1157..0000000 --- a/test/workos/organizations/organizations_test.exs +++ /dev/null @@ -1,124 +0,0 @@ -defmodule WorkOS.OrganizationsTest do - use ExUnit.Case - doctest WorkOS.Organizations - import Tesla.Mock - - alias WorkOS.Organizations - - describe "#list_organizations/1" do - setup do - mock(fn - %{method: :get, url: "https://api.workos.com/organizations"} -> - %Tesla.Env{status: 200, body: "Success"} - end) - - :ok - end - - test "returns a 200 status" do - assert {:ok, "Success"} = Organizations.list_organizations() - end - end - - describe "#create_organization/1 with a name and domain" do - setup do - mock(fn - %{method: :post, url: "https://api.workos.com/organizations"} -> - %Tesla.Env{status: 200, body: "Success"} - end) - - :ok - end - - test "returns a 200 status" do - assert {:ok, "Success"} = - Organizations.create_organization(%{ - name: "Test Corp", - domains: ["workos.com"] - }) - end - end - - describe "#get_organization/2 with an valid id" do - setup do - mock(fn - %{method: :get, url: "https://api.workos.com/organizations/org_12345"} -> - %Tesla.Env{status: 200, body: "Success"} - end) - - :ok - end - - test "returns a 200 status" do - assert {:ok, "Success"} = Organizations.get_organization('org_12345') - end - end - - describe "#update_organization/2 with a valid id" do - setup do - mock(fn - %{method: :put, url: "https://api.workos.com/organizations/org_12345"} -> - %Tesla.Env{status: 200, body: "Success"} - end) - - :ok - end - - test "returns a 200 status" do - assert {:ok, "Success"} = - Organizations.update_organization('org_12345', %{ - name: "WorkOS", - domains: ["workos.com"] - }) - end - end - - describe "#update_organization/2 with an invalid id" do - setup do - mock(fn - %{method: :put, url: "https://api.workos.com/organizations/invalid"} -> - %Tesla.Env{status: 404, body: "Not Found"} - end) - - :ok - end - - test "returns a 404 status" do - assert {:error, "Not Found"} = - Organizations.update_organization('invalid', %{ - name: "WorkOS", - domains: ["workos.com"] - }) - end - end - - describe "#delete_organization/1 with a valid id" do - setup do - mock(fn - %{method: :delete, url: "https://api.workos.com/organizations/org_12345"} -> - %Tesla.Env{status: 200, body: "Success"} - end) - - :ok - end - - test "returns a 200 status" do - assert {:ok, "Success"} = Organizations.delete_organization('org_12345') - end - end - - describe "#delete_organization/1 with an invalid id" do - setup do - mock(fn - %{method: :delete, url: "https://api.workos.com/organizations/invalid"} -> - %Tesla.Env{status: 404, body: "Not Found"} - end) - - :ok - end - - test "returns a 404 status" do - assert {:error, "Not Found"} = Organizations.delete_organization('invalid') - end - end -end diff --git a/test/workos/organizations_test.exs b/test/workos/organizations_test.exs new file mode 100644 index 0000000..15b0b60 --- /dev/null +++ b/test/workos/organizations_test.exs @@ -0,0 +1,105 @@ +defmodule WorkOS.OrganizationsTest do + use WorkOS.TestCase + + alias WorkOS.Organizations.ClientMock + + setup :setup_env + + describe "list_organizations" do + test "without any options, returns organizations and metadata", context do + context + |> ClientMock.list_organizations() + + assert {:ok, + %WorkOS.List{ + data: [%WorkOS.Organizations.Organization{}, %WorkOS.Organizations.Organization{}], + list_metadata: %{} + }} = WorkOS.Organizations.list_organizations() + end + + test "with the domain option, forms the proper request to the API", context do + opts = [domains: ["example.com"]] + + context + |> ClientMock.list_organizations(assert_fields: opts) + + assert {:ok, + %WorkOS.List{ + data: [%WorkOS.Organizations.Organization{}, %WorkOS.Organizations.Organization{}], + list_metadata: %{} + }} = WorkOS.Organizations.list_organizations(opts |> Enum.into(%{})) + end + end + + describe "delete_organization" do + test "sends a request to delete an organization", context do + opts = [organization_id: "org_01EHT88Z8J8795GZNQ4ZP1J81T"] + + context |> ClientMock.delete_organization(assert_fields: opts) + + assert {:ok, %WorkOS.Empty{}} = + WorkOS.Organizations.delete_organization(opts |> Keyword.get(:organization_id)) + end + end + + describe "get_organization" do + test "requests an organization", context do + opts = [organization_id: "org_01EHT88Z8J8795GZNQ4ZP1J81T"] + + context |> ClientMock.get_organization(assert_fields: opts) + + assert {:ok, %WorkOS.Organizations.Organization{id: id}} = + WorkOS.Organizations.get_organization(opts |> Keyword.get(:organization_id)) + + refute is_nil(id) + end + end + + describe "create_organization" do + test "with an idempotency key, includes an idempotency key with request", context do + opts = [ + domains: ["example.com"], + name: "Test Organization", + idempotency_key: "the-idempotency-key" + ] + + context |> ClientMock.create_organization(assert_fields: opts) + + assert {:ok, %WorkOS.Organizations.Organization{id: id}} = + WorkOS.Organizations.create_organization(opts |> Enum.into(%{})) + + refute is_nil(id) + end + + test "with a valid payload, creates an organization", context do + opts = [domains: ["example.com"], name: "Test Organization"] + + context |> ClientMock.create_organization(assert_fields: opts) + + assert {:ok, %WorkOS.Organizations.Organization{id: id}} = + WorkOS.Organizations.create_organization(opts |> Enum.into(%{})) + + refute is_nil(id) + end + end + + describe "update_organization" do + test "with a valid payload, updates an organization", context do + opts = [ + organization_id: "org_01EHT88Z8J8795GZNQ4ZP1J81T", + domains: ["example.com"], + name: "Test Organization 2" + ] + + context |> ClientMock.update_organization(assert_fields: opts) + + assert {:ok, %WorkOS.Organizations.Organization{id: id}} = + WorkOS.Organizations.update_organization( + opts |> Keyword.get(:organization_id), + opts |> Enum.into(%{}) + ) + + refute is_nil(id) + end + end +end diff --git a/test/workos/passwordless/passwordless_test.exs b/test/workos/passwordless/passwordless_test.exs deleted file mode 100644 index 9ff9aeb..0000000 --- a/test/workos/passwordless/passwordless_test.exs +++ /dev/null @@ -1,4 +0,0 @@ -defmodule WorkOS.PasswordlessTest do - use ExUnit.Case - doctest WorkOS.Passwordless -end diff --git a/test/workos/passwordless_test.exs b/test/workos/passwordless_test.exs new file mode 100644 index 0000000..594a803 --- /dev/null +++ b/test/workos/passwordless_test.exs @@ -0,0 +1,37 @@ +defmodule WorkOS.PasswordlessTest do + use WorkOS.TestCase + + alias WorkOS.Passwordless.ClientMock + + setup :setup_env + + describe "create_session" do + test "with valid options, creates a passwordless session", context do + opts = [ + email: "passwordless-session-email@workos.com", + type: "MagicLink", + redirect_uri: "https://example.com/passwordless/callback" + ] + + context |> ClientMock.create_session(assert_fields: opts) + + assert {:ok, %WorkOS.Passwordless.Session{email: email}} = + WorkOS.Passwordless.create_session(opts |> Enum.into(%{})) + + refute is_nil(email) + end + end + + describe "send_session" do + test "with a valid session id, sends a request to send a magic link email", context do + opts = [session_id: "session_123"] + + context |> ClientMock.send_session(assert_fields: opts) + + assert {:ok, %WorkOS.Passwordless.Session.Send{success: success}} = + WorkOS.Passwordless.send_session(opts |> Keyword.get(:session_id)) + + assert success == true + end + end +end diff --git a/test/workos/portal/portal_test.exs b/test/workos/portal/portal_test.exs deleted file mode 100644 index 0b55dfb..0000000 --- a/test/workos/portal/portal_test.exs +++ /dev/null @@ -1,4 +0,0 @@ -defmodule WorkOS.PortalTest do - use ExUnit.Case - doctest WorkOS.Portal -end diff --git a/test/workos/portal_test.exs b/test/workos/portal_test.exs new file mode 100644 index 0000000..44d03ed --- /dev/null +++ b/test/workos/portal_test.exs @@ -0,0 +1,103 @@ +defmodule WorkOS.PortalTest do + use WorkOS.TestCase + + alias WorkOS.Portal.ClientMock + + setup :setup_env + + describe "generate_link" do + test "with invalid intent, raises an error", context do + opts = [ + intent: "invalid" + ] + + context |> ClientMock.generate_link(assert_fields: opts) + + assert_raise ArgumentError, fn -> + WorkOS.Portal.generate_link(opts |> Enum.into(%{})) + end + end + + test "with a valid intent and without an organization, raises an error", context do + opts = [ + intent: "sso" + ] + + context |> ClientMock.generate_link(assert_fields: opts) + + assert_raise ArgumentError, fn -> + WorkOS.Portal.generate_link(opts |> Enum.into(%{})) + end + end + + test "with a audit_logs intent, returns portal link", context do + opts = [ + intent: "audit_logs", + organization: "org_01EHQMYV6MBK39QC5PZXHY59C3" + ] + + context |> ClientMock.generate_link(assert_fields: opts) + + assert {:ok, %WorkOS.Portal.Link{link: link}} = + WorkOS.Portal.generate_link(opts |> Enum.into(%{})) + + refute is_nil(link) + end + + test "with a domain_verification intent, returns portal link", context do + opts = [ + intent: "domain_verification", + organization: "org_01EHQMYV6MBK39QC5PZXHY59C3" + ] + + context |> ClientMock.generate_link(assert_fields: opts) + + assert {:ok, %WorkOS.Portal.Link{link: link}} = + WorkOS.Portal.generate_link(opts |> Enum.into(%{})) + + refute is_nil(link) + end + + test "with a dsync intent, returns portal link", context do + opts = [ + intent: "dsync", + organization: "org_01EHQMYV6MBK39QC5PZXHY59C3" + ] + + context |> ClientMock.generate_link(assert_fields: opts) + + assert {:ok, %WorkOS.Portal.Link{link: link}} = + WorkOS.Portal.generate_link(opts |> Enum.into(%{})) + + refute is_nil(link) + end + + test "with a log_streams intent, returns portal link", context do + opts = [ + intent: "log_streams", + organization: "org_01EHQMYV6MBK39QC5PZXHY59C3" + ] + + context |> ClientMock.generate_link(assert_fields: opts) + + assert {:ok, %WorkOS.Portal.Link{link: link}} = + WorkOS.Portal.generate_link(opts |> Enum.into(%{})) + + refute is_nil(link) + end + + test "with a sso intent, returns portal link", context do + opts = [ + intent: "sso", + organization: "org_01EHQMYV6MBK39QC5PZXHY59C3" + ] + + context |> ClientMock.generate_link(assert_fields: opts) + + assert {:ok, %WorkOS.Portal.Link{link: link}} = + WorkOS.Portal.generate_link(opts |> Enum.into(%{})) + + refute is_nil(link) + end + end +end diff --git a/test/workos/sso/sso_test.exs b/test/workos/sso/sso_test.exs deleted file mode 100644 index c063e71..0000000 --- a/test/workos/sso/sso_test.exs +++ /dev/null @@ -1,198 +0,0 @@ -defmodule WorkOS.SSOTest do - use ExUnit.Case - doctest WorkOS.SSO - alias WorkOS.SSO - import Tesla.Mock - - def parse_uri(url) do - uri = URI.parse(url) - %URI{uri | query: URI.query_decoder(uri.query) |> Enum.to_list()} - end - - describe "#get_authorization_url/2 with a custom client_id" do - setup do - {:ok, url} = - SSO.get_authorization_url( - %{domain: "test", provider: "GoogleOAuth", redirect_uri: "project", state: "nope"}, - client_id: "8vf9xg" - ) - - {:ok, url: URI.parse(url)} - end - - test "returns the expected query string", %{url: %URI{query: query}} do - params = URI.query_decoder(query) |> Enum.to_list() - assert {"client_id", "8vf9xg"} in params - end - end - - describe "#get_authorization_url/2 with a domain" do - setup do - {:ok, url} = - SSO.get_authorization_url(%{ - domain: "test", - provider: "GoogleOAuth", - redirect_uri: "project", - state: "nope" - }) - - {:ok, url: URI.parse(url)} - end - - test "returns a valid URL", %{url: url} do - assert %URI{} = url - end - - test "returns the expected hostname", %{url: url} do - assert url.host == WorkOS.host() - end - - test "returns the expected query string", %{url: %URI{query: query}} do - params = URI.query_decoder(query) |> Enum.to_list() - assert {"domain", "test"} in params - end - end - - describe "#get_authorization_url/2 with a provider" do - setup do - {:ok, url} = - SSO.get_authorization_url(%{ - domain: "test", - provider: "GoogleOAuth", - redirect_uri: "project", - state: "nope" - }) - - {:ok, url: URI.parse(url)} - end - - test "returns a valid URL", %{url: url} do - assert %URI{} = url - end - - test "returns the expected hostname", %{url: url} do - assert url.host == WorkOS.host() - end - - test "returns the expected query string", %{url: %URI{query: query}} do - params = URI.query_decoder(query) |> Enum.to_list() - assert {"domain", "test"} in params - end - end - - describe "#get_authorization_url/2 with a connection" do - setup do - {:ok, url} = - SSO.get_authorization_url(%{ - connection: "connection_123", - redirect_uri: "project", - state: "nope" - }) - - {:ok, url: URI.parse(url)} - end - - test "returns a valid URL", %{url: url} do - assert %URI{} = url - end - - test "returns the expected hostname", %{url: url} do - assert url.host == WorkOS.host() - end - - test "returns the expected query string", %{url: %URI{query: query}} do - params = URI.query_decoder(query) |> Enum.to_list() - assert {"connection", "connection_123"} in params - end - end - - describe "#get_authorization_url/2 with neither connection, domain, nor provider" do - test "returns an error" do - assert_raise ArgumentError, fn -> - {:ok, url: SSO.get_authorization_url(%{redirect_uri: "project", state: "nope"})} - end - end - end - - describe "#list_connections/1" do - setup do - mock(fn - %{method: :get, url: "https://api.workos.com/connections"} -> - %Tesla.Env{status: 200, body: "Success"} - end) - - :ok - end - - test "returns a 200 status" do - assert {:ok, "Success"} = SSO.list_connections() - end - end - - describe "#get_connection/2 with an valid id" do - setup do - mock(fn - %{method: :get, url: "https://api.workos.com/connections/conn_12345"} -> - %Tesla.Env{status: 200, body: "Success"} - end) - - :ok - end - - test "returns a 200 status" do - assert {:ok, "Success"} = SSO.get_connection('conn_12345') - end - end - - describe "#delete_connection/1 with a valid id" do - setup do - mock(fn - %{method: :delete, url: "https://api.workos.com/connections/conn_12345"} -> - %Tesla.Env{status: 200, body: "Success"} - end) - - :ok - end - - test "returns a 200 status" do - assert {:ok, "Success"} = SSO.delete_connection('conn_12345') - end - end - - describe "#delete_connection/1 with an invalid id" do - setup do - mock(fn - %{method: :delete, url: "https://api.workos.com/connections/invalid"} -> - %Tesla.Env{status: 404, body: "Not Found"} - end) - - :ok - end - - test "returns a 404 status" do - assert {:error, "Not Found"} = SSO.delete_connection('invalid') - end - end - - describe "#get_profile/2" do - setup do - access_token = "test_access_token" - auth_header = "Bearer #{access_token}" - - mock(fn - %{ - method: :get, - url: "https://api.workos.com/sso/profile", - headers: [{"Authorization", ^auth_header}] - } -> - %Tesla.Env{status: 200, body: "Success"} - end) - - %{access_token: access_token} - end - - test "returns a 200 status", %{access_token: access_token} do - assert {:ok, "Success"} = SSO.get_profile(access_token) - end - end -end diff --git a/test/workos/sso_test.exs b/test/workos/sso_test.exs new file mode 100644 index 0000000..6bf0e34 --- /dev/null +++ b/test/workos/sso_test.exs @@ -0,0 +1,204 @@ +defmodule WorkOS.SSOTest do + use WorkOS.TestCase + + alias WorkOS.SSO.ClientMock + + setup :setup_env + + def parse_uri(url) do + uri = URI.parse(url) + %URI{uri | query: URI.query_decoder(uri.query) |> Enum.to_list()} + end + + describe "get_authorization_url" do + test "generates an authorize url with the default `base_url`" do + opts = [connection: "mock-connection-id", redirect_uri: "example.com/sso/workos/callback"] + + assert {:ok, success_url} = opts |> Map.new() |> WorkOS.SSO.get_authorization_url() + + assert WorkOS.base_url() =~ parse_uri(success_url).host + end + + test "with no `domain`, `provider`, `connection` or `organization`, returns error for incomplete arguments" do + opts = [redirect_uri: "example.com/sso/workos/callback"] + + assert {:error, _error_message} = opts |> Map.new() |> WorkOS.SSO.get_authorization_url() + end + + test "with no `redirect_uri`, returns error for incomplete arguments" do + opts = [provider: "GoogleOAuth"] + + assert {:error, _error_message} = opts |> Map.new() |> WorkOS.SSO.get_authorization_url() + end + + test "generates an authorize url with a `provider`" do + opts = [provider: "MicrosoftOAuth", redirect_uri: "example.com/sso/workos/callback"] + + assert {:ok, success_url} = opts |> Map.new() |> WorkOS.SSO.get_authorization_url() + + assert {"provider", "MicrosoftOAuth"} in parse_uri(success_url).query + end + + test "generates an authorize url with a `connection`" do + opts = [connection: "mock-connection-id", redirect_uri: "example.com/sso/workos/callback"] + + assert {:ok, success_url} = opts |> Map.new() |> WorkOS.SSO.get_authorization_url() + + assert {"connection", "mock-connection-id"} in parse_uri(success_url).query + end + + test "generates an authorization url with a `organization`" do + opts = [organization: "mock-organization", redirect_uri: "example.com/sso/workos/callback"] + + assert {:ok, success_url} = opts |> Map.new() |> WorkOS.SSO.get_authorization_url() + + assert {"organization", "mock-organization"} in parse_uri(success_url).query + end + + test "generates an authorization url with a custom `base_url` from app config" do + initial_config = Application.get_env(:workos, WorkOS.Client) + + Application.put_env( + :workos, + WorkOS.Client, + Keyword.put(initial_config, :base_url, "https://custom-base-url.com") + ) + + opts = [provider: "GoogleOAuth", redirect_uri: "example.com/sso/workos/callback"] + + assert {:ok, success_url} = opts |> Map.new() |> WorkOS.SSO.get_authorization_url() + + assert "custom-base-url.com" == parse_uri(success_url).host + + Application.put_env(:workos, WorkOS.Client, initial_config) + end + + test "generates an authorization url with a `state`" do + opts = [ + provider: "GoogleOAuth", + state: "mock-state", + redirect_uri: "example.com/sso/workos/callback" + ] + + assert {:ok, success_url} = opts |> Map.new() |> WorkOS.SSO.get_authorization_url() + + assert {"state", "mock-state"} in parse_uri(success_url).query + end + + test "generates an authorization url with a given `domain_hint`" do + opts = [ + organization: "mock-organization", + domain_hint: "mock-domain-hint", + redirect_uri: "example.com/sso/workos/callback" + ] + + assert {:ok, success_url} = opts |> Map.new() |> WorkOS.SSO.get_authorization_url() + + assert {"domain_hint", "mock-domain-hint"} in parse_uri(success_url).query + end + + test "generates an authorization url with a given `login_hint`" do + opts = [ + organization: "mock-organization", + login_hint: "mock-login-hint", + redirect_uri: "example.com/sso/workos/callback" + ] + + assert {:ok, success_url} = opts |> Map.new() |> WorkOS.SSO.get_authorization_url() + + assert {"login_hint", "mock-login-hint"} in parse_uri(success_url).query + end + + test "with a invalid selector, returns error" do + opts = [ + redirect_uri: "example.com/sso/workos/callback" + ] + + {:error, _message} = opts |> Map.new() |> WorkOS.UserManagement.get_authorization_url() + end + end + + describe "get_profile_and_token" do + test "with all information provided, sends a request to the WorkOS API for a profile", + context do + opts = [code: "authorization_code"] + + context |> ClientMock.get_profile_and_token(assert_fields: opts) + + assert {:ok, %WorkOS.SSO.ProfileAndToken{access_token: access_token, profile: profile}} = + WorkOS.SSO.get_profile_and_token(opts |> Keyword.get(:code)) + + refute is_nil(access_token) + refute is_nil(profile) + end + + test "without a groups attribute, sends a request to the WorkOS API for a profile", context do + opts = [code: "authorization_code"] + + context + |> Map.put(:with_group_attribute, false) + |> ClientMock.get_profile_and_token(assert_fields: opts) + + {:ok, %WorkOS.SSO.ProfileAndToken{access_token: access_token, profile: profile}} = + WorkOS.SSO.get_profile_and_token(opts |> Keyword.get(:code)) + + refute is_nil(access_token) + refute is_nil(profile) + end + end + + describe "get_profile" do + test "calls the `/sso/profile` endpoint with the provided access token", context do + opts = [access_token: "access_token"] + + context |> ClientMock.get_profile(assert_fields: opts) + + assert {:ok, %WorkOS.SSO.Profile{id: id}} = + WorkOS.SSO.get_profile(opts |> Keyword.get(:access_token)) + + refute is_nil(id) + end + end + + describe "get_connection" do + test "requests a connection", context do + opts = [connection_id: "conn_123"] + + context |> ClientMock.get_connection(assert_fields: opts) + + assert {:ok, %WorkOS.SSO.Connection{id: id}} = + WorkOS.SSO.get_connection(opts |> Keyword.get(:connection_id)) + + refute is_nil(id) + end + end + + describe "list_connections" do + test "requests a list of connections", context do + opts = [organization_id: "org_1234"] + + context |> ClientMock.list_connections(assert_fields: opts) + + assert {:ok, %WorkOS.List{data: [%WorkOS.SSO.Connection{}], list_metadata: %{}}} = + WorkOS.SSO.list_connections(opts |> Enum.into(%{})) + end + + test "without any options, returns connections and metadata", context do + context |> ClientMock.list_connections() + + assert {:ok, %WorkOS.List{data: [%WorkOS.SSO.Connection{}], list_metadata: %{}}} = + WorkOS.SSO.list_connections() + end + end + + describe "delete_connection" do + test "sends a request to delete a connection", context do + opts = [connection_id: "conn_123"] + + context |> ClientMock.delete_connection(assert_fields: opts) + + assert {:ok, %WorkOS.Empty{}} = + WorkOS.SSO.delete_connection(opts |> Keyword.get(:connection_id)) + end + end +end diff --git a/test/workos/user_management_test.exs b/test/workos/user_management_test.exs new file mode 100644 index 0000000..d55019b --- /dev/null +++ b/test/workos/user_management_test.exs @@ -0,0 +1,547 @@ +defmodule WorkOS.UserManagementTest do + use WorkOS.TestCase + + alias WorkOS.UserManagement.ClientMock + + setup :setup_env + + def parse_uri(url) do + uri = URI.parse(url) + %URI{uri | query: URI.query_decoder(uri.query) |> Enum.to_list()} + end + + describe "get_authorization_url" do + test "generates an authorize url with the default `base_url`" do + opts = [ + connection_id: "mock-connection-id", + redirect_uri: "example.com/sso/workos/callback" + ] + + assert {:ok, success_url} = + opts |> Map.new() |> WorkOS.UserManagement.get_authorization_url() + + assert WorkOS.base_url() =~ parse_uri(success_url).host + end + + test "generates an authorize url with a `provider`" do + opts = [provider: "MicrosoftOAuth", redirect_uri: "example.com/sso/workos/callback"] + + assert {:ok, success_url} = + opts |> Map.new() |> WorkOS.UserManagement.get_authorization_url() + + assert {"provider", "MicrosoftOAuth"} in parse_uri(success_url).query + end + + test "generates an authorize url with a `connection_id`" do + opts = [ + connection_id: "mock-connection-id", + redirect_uri: "example.com/sso/workos/callback" + ] + + assert {:ok, success_url} = + opts |> Map.new() |> WorkOS.UserManagement.get_authorization_url() + + assert {"connection_id", "mock-connection-id"} in parse_uri(success_url).query + end + + test "generates an authorization url with a `organization_id`" do + opts = [ + organization_id: "mock-organization", + redirect_uri: "example.com/sso/workos/callback" + ] + + assert {:ok, success_url} = + opts |> Map.new() |> WorkOS.UserManagement.get_authorization_url() + + assert {"organization_id", "mock-organization"} in parse_uri(success_url).query + end + + test "generates an authorization url with a custom `base_url` from app config" do + initial_config = Application.get_env(:workos, WorkOS.Client) + + Application.put_env( + :workos, + WorkOS.Client, + Keyword.put(initial_config, :base_url, "https://custom-base-url.com") + ) + + opts = [provider: "GoogleOAuth", redirect_uri: "example.com/sso/workos/callback"] + + assert {:ok, success_url} = + opts |> Map.new() |> WorkOS.UserManagement.get_authorization_url() + + assert "custom-base-url.com" == parse_uri(success_url).host + + Application.put_env(:workos, WorkOS.Client, initial_config) + end + + test "generates an authorization url with a `state`" do + opts = [ + provider: "GoogleOAuth", + state: "mock-state", + redirect_uri: "example.com/sso/workos/callback" + ] + + assert {:ok, success_url} = + opts |> Map.new() |> WorkOS.UserManagement.get_authorization_url() + + assert {"state", "mock-state"} in parse_uri(success_url).query + end + + test "generates an authorization url with a given `domain_hint`" do + opts = [ + organization_id: "mock-organization", + domain_hint: "mock-domain-hint", + redirect_uri: "example.com/sso/workos/callback" + ] + + assert {:ok, success_url} = + opts |> Map.new() |> WorkOS.UserManagement.get_authorization_url() + + assert {"domain_hint", "mock-domain-hint"} in parse_uri(success_url).query + end + + test "generates an authorization url with a given `login_hint`" do + opts = [ + organization_id: "mock-organization", + login_hint: "mock-login-hint", + redirect_uri: "example.com/sso/workos/callback" + ] + + assert {:ok, success_url} = + opts |> Map.new() |> WorkOS.UserManagement.get_authorization_url() + + assert {"login_hint", "mock-login-hint"} in parse_uri(success_url).query + end + + test "with a invalid selector, returns error" do + opts = [ + redirect_uri: "example.com/sso/workos/callback" + ] + + {:error, _message} = opts |> Map.new() |> WorkOS.UserManagement.get_authorization_url() + end + end + + describe "get_user" do + test "requests a user", context do + opts = [user_id: "user_01H5JQDV7R7ATEYZDEG0W5PRYS"] + + context |> ClientMock.get_user(assert_fields: opts) + + assert {:ok, %WorkOS.UserManagement.User{id: id}} = + WorkOS.UserManagement.get_user(opts |> Keyword.get(:user_id)) + + refute is_nil(id) + end + end + + describe "list_users" do + test "without any options, returns users and metadata", context do + context + |> ClientMock.list_users() + + assert {:ok, + %WorkOS.List{ + data: [%WorkOS.UserManagement.User{}], + list_metadata: %{} + }} = WorkOS.UserManagement.list_users() + end + + test "with the email option, forms the proper request to the API", context do + opts = [email: "test@example.com"] + + context + |> ClientMock.list_users(assert_fields: opts) + + assert {:ok, + %WorkOS.List{ + data: [%WorkOS.UserManagement.User{}], + list_metadata: %{} + }} = WorkOS.UserManagement.list_users() + end + end + + describe "create_user" do + test "with a valid payload, creates a user", context do + opts = [email: "test@example.com"] + + context |> ClientMock.create_user(assert_fields: opts) + + assert {:ok, %WorkOS.UserManagement.User{id: id}} = + WorkOS.UserManagement.create_user(opts |> Enum.into(%{})) + + refute is_nil(id) + end + end + + describe "update_user" do + test "with a valid payload, updates a user", context do + opts = [ + user_id: "user_01H5JQDV7R7ATEYZDEG0W5PRYS", + first_name: "Foo test", + last_name: "Foo test" + ] + + context |> ClientMock.update_user(assert_fields: opts) + + assert {:ok, %WorkOS.UserManagement.User{id: id}} = + WorkOS.UserManagement.update_user( + opts |> Keyword.get(:user_id), + opts |> Enum.into(%{}) + ) + + refute is_nil(id) + end + end + + describe "delete_user" do + test "sends a request to delete a user", context do + opts = [user_id: "user_01H5JQDV7R7ATEYZDEG0W5PRYS"] + + context |> ClientMock.delete_user(assert_fields: opts) + + assert {:ok, %WorkOS.Empty{}} = + WorkOS.UserManagement.delete_user(opts |> Keyword.get(:user_id)) + end + end + + describe "authenticate_with_password" do + test "with a valid payload, authenticates with password", context do + opts = [email: "test@example.com", password: "foo-bar"] + + context |> ClientMock.authenticate(assert_fields: opts) + + assert {:ok, %WorkOS.UserManagement.Authentication{user: user}} = + WorkOS.UserManagement.authenticate_with_password(opts |> Enum.into(%{})) + + refute is_nil(user["id"]) + end + end + + describe "authenticate_with_code" do + test "with a valid payload, authenticates with code", context do + opts = [code: "foo-bar"] + + context |> ClientMock.authenticate(assert_fields: opts) + + assert {:ok, %WorkOS.UserManagement.Authentication{user: user}} = + WorkOS.UserManagement.authenticate_with_code(opts |> Enum.into(%{})) + + refute is_nil(user["id"]) + end + end + + describe "authenticate_with_magic_auth" do + test "with a valid payload, authenticates with magic auth", context do + opts = [code: "foo-bar", email: "test@example.com"] + + context |> ClientMock.authenticate(assert_fields: opts) + + assert {:ok, %WorkOS.UserManagement.Authentication{user: user}} = + WorkOS.UserManagement.authenticate_with_magic_auth(opts |> Enum.into(%{})) + + refute is_nil(user["id"]) + end + end + + describe "authenticate_with_email_verification" do + test "with a valid payload, authenticates with email verification", context do + opts = [code: "foo-bar", pending_authentication_code: "foo-bar"] + + context |> ClientMock.authenticate(assert_fields: opts) + + assert {:ok, %WorkOS.UserManagement.Authentication{user: user}} = + WorkOS.UserManagement.authenticate_with_email_verification(opts |> Enum.into(%{})) + + refute is_nil(user["id"]) + end + end + + describe "authenticate_with_totp" do + test "with a valid payload, authenticates with MFA TOTP", context do + opts = [ + code: "foo-bar", + authentication_challenge_id: "auth_challenge_1234", + pending_authentication_code: "foo-bar" + ] + + context |> ClientMock.authenticate(assert_fields: opts) + + assert {:ok, %WorkOS.UserManagement.Authentication{user: user}} = + WorkOS.UserManagement.authenticate_with_totp(opts |> Enum.into(%{})) + + refute is_nil(user["id"]) + end + end + + describe "authenticate_with_selected_organization" do + test "with a valid payload, authenticates with a selected organization", context do + opts = [ + pending_authentication_code: "foo-bar", + organization_id: "organization_01H5JQDV7R7ATEYZDEG0W5PRYS" + ] + + context |> ClientMock.authenticate(assert_fields: opts) + + assert {:ok, %WorkOS.UserManagement.Authentication{user: user}} = + WorkOS.UserManagement.authenticate_with_selected_organization( + opts + |> Enum.into(%{}) + ) + + refute is_nil(user["id"]) + end + end + + describe "send_magic_auth_code" do + test "with a valid payload, sends magic auth code email", context do + opts = [ + email: "test@example.com" + ] + + context |> ClientMock.send_magic_auth_code(assert_fields: opts) + + assert :ok = WorkOS.UserManagement.send_magic_auth_code(opts |> Keyword.get(:email)) + end + end + + describe "enroll_auth_factor" do + test "with a valid payload, enrolls auth factor", context do + opts = [ + type: "totp", + user_id: "user_01H5JQDV7R7ATEYZDEG0W5PRYS" + ] + + context |> ClientMock.enroll_auth_factor(assert_fields: opts) + + assert {:ok, + %WorkOS.UserManagement.MultiFactor.EnrollAuthFactor{ + challenge: challenge, + factor: factor + }} = + WorkOS.UserManagement.enroll_auth_factor( + opts |> Keyword.get(:user_id), + opts |> Enum.into(%{}) + ) + + refute is_nil(challenge["id"]) + refute is_nil(factor["id"]) + end + end + + describe "list_auth_factors" do + test "without any options, returns auth factors and metadata", context do + opts = [ + user_id: "user_01H5JQDV7R7ATEYZDEG0W5PRYS" + ] + + context + |> ClientMock.list_auth_factors(assert_fields: opts) + + assert {:ok, + %WorkOS.List{ + data: [%WorkOS.UserManagement.MultiFactor.AuthenticationFactor{}], + list_metadata: %{} + }} = WorkOS.UserManagement.list_auth_factors(opts |> Keyword.get(:user_id)) + end + end + + describe "send_verification_email" do + test "with a valid payload, revokes an invitation", context do + opts = [user_id: "user_01H5JQDV7R7ATEYZDEG0W5PRYS"] + + context |> ClientMock.send_verification_email(assert_fields: opts) + + assert {:ok, %WorkOS.UserManagement.EmailVerification.SendVerificationEmail{user: user}} = + WorkOS.UserManagement.send_verification_email(opts |> Keyword.get(:user_id)) + + refute is_nil(user["id"]) + end + end + + describe "verify_email" do + test "with a valid payload, verifies user email", context do + opts = [ + user_id: "user_01H5JQDV7R7ATEYZDEG0W5PRYS", + code: "Foo test" + ] + + context |> ClientMock.verify_email(assert_fields: opts) + + assert {:ok, %WorkOS.UserManagement.EmailVerification.VerifyEmail{user: user}} = + WorkOS.UserManagement.verify_email( + opts |> Keyword.get(:user_id), + opts |> Enum.into(%{}) + ) + + refute is_nil(user["id"]) + end + end + + describe "send_password_reset_email" do + test "with a valid payload, sends password reset email", context do + opts = [ + email: "test@example.com", + password_reset_url: "https://reset-password-test.com" + ] + + context |> ClientMock.send_password_reset_email(assert_fields: opts) + + assert :ok = WorkOS.UserManagement.send_password_reset_email(opts |> Enum.into(%{})) + end + end + + describe "reset_password" do + test "with a valid payload, resets password", context do + opts = [ + token: ~c"test", + new_password: ~c"test" + ] + + context |> ClientMock.reset_password(assert_fields: opts) + + assert {:ok, %WorkOS.UserManagement.ResetPassword{user: user}} = + WorkOS.UserManagement.reset_password(opts |> Enum.into(%{})) + + refute is_nil(user["id"]) + end + end + + describe "get_organization_membership" do + test "requests an organization membership", context do + opts = [organization_membership_id: "om_01H5JQDV7R7ATEYZDEG0W5PRYS"] + + context |> ClientMock.get_organization_membership(assert_fields: opts) + + assert {:ok, %WorkOS.UserManagement.OrganizationMembership{id: id}} = + WorkOS.UserManagement.get_organization_membership( + opts + |> Keyword.get(:organization_membership_id) + ) + + refute is_nil(id) + end + end + + describe "list_organization_memberships" do + test "without any options, returns organization memberships and metadata", context do + context + |> ClientMock.list_organization_memberships() + + assert {:ok, + %WorkOS.List{ + data: [%WorkOS.UserManagement.OrganizationMembership{}], + list_metadata: %{} + }} = WorkOS.UserManagement.list_organization_memberships() + end + + test "with the user_id option, forms the proper request to the API", context do + opts = [user_id: "user_01H5JQDV7R7ATEYZDEG0W5PRYS"] + + context + |> ClientMock.list_organization_memberships(assert_fields: opts) + + assert {:ok, + %WorkOS.List{ + data: [%WorkOS.UserManagement.OrganizationMembership{}], + list_metadata: %{} + }} = WorkOS.UserManagement.list_organization_memberships() + end + end + + describe "create_organization_membership" do + test "with a valid payload, creates an organization membership", context do + opts = [ + user_id: "user_01H5JQDV7R7ATEYZDEG0W5PRYS", + organization_id: "org_01EHT88Z8J8795GZNQ4ZP1J81T" + ] + + context |> ClientMock.create_organization_membership(assert_fields: opts) + + assert {:ok, %WorkOS.UserManagement.OrganizationMembership{id: id}} = + WorkOS.UserManagement.create_organization_membership(opts |> Enum.into(%{})) + + refute is_nil(id) + end + end + + describe "delete_organization_membership" do + test "sends a request to delete an organization membership", context do + opts = [organization_membership_id: "om_01H5JQDV7R7ATEYZDEG0W5PRYS"] + + context |> ClientMock.delete_organization_membership(assert_fields: opts) + + assert {:ok, %WorkOS.Empty{}} = + WorkOS.UserManagement.delete_organization_membership( + opts + |> Keyword.get(:organization_membership_id) + ) + end + end + + describe "list_invitations" do + test "without any options, returns invitations and metadata", context do + context + |> ClientMock.list_invitations() + + assert {:ok, + %WorkOS.List{ + data: [%WorkOS.UserManagement.Invitation{}], + list_metadata: %{} + }} = WorkOS.UserManagement.list_invitations() + end + + test "with the email option, forms the proper request to the API", context do + opts = [email: "test@example.com"] + + context + |> ClientMock.list_invitations(assert_fields: opts) + + assert {:ok, + %WorkOS.List{ + data: [%WorkOS.UserManagement.Invitation{}], + list_metadata: %{} + }} = WorkOS.UserManagement.list_invitations() + end + end + + describe "get_invitation" do + test "requests an invitation", context do + opts = [invitation_id: "invitation_01H5JQDV7R7ATEYZDEG0W5PRYS"] + + context |> ClientMock.get_invitation(assert_fields: opts) + + assert {:ok, %WorkOS.UserManagement.Invitation{id: id}} = + WorkOS.UserManagement.get_invitation(opts |> Keyword.get(:invitation_id)) + + refute is_nil(id) + end + end + + describe "send_invitation" do + test "with a valid payload, creates an invitation", context do + opts = [email: "test@example.com"] + + context |> ClientMock.send_invitation(assert_fields: opts) + + assert {:ok, %WorkOS.UserManagement.Invitation{id: id}} = + WorkOS.UserManagement.send_invitation(opts |> Enum.into(%{})) + + refute is_nil(id) + end + end + + describe "revoke_invitation" do + test "with a valid payload, revokes an invitation", context do + opts = [invitation_id: "invitation_01H5JQDV7R7ATEYZDEG0W5PRYS"] + + context |> ClientMock.revoke_invitation(assert_fields: opts) + + assert {:ok, %WorkOS.UserManagement.Invitation{id: id}} = + WorkOS.UserManagement.revoke_invitation(opts |> Keyword.get(:invitation_id)) + + refute is_nil(id) + end + end +end diff --git a/test/workos/webhooks/webhooks_test.exs b/test/workos/webhooks_test.exs similarity index 100% rename from test/workos/webhooks/webhooks_test.exs rename to test/workos/webhooks_test.exs diff --git a/test/workos_test.exs b/test/workos_test.exs index 8182b48..1d70be5 100755 --- a/test/workos_test.exs +++ b/test/workos_test.exs @@ -1,42 +1,3 @@ defmodule WorkOSTest do use ExUnit.Case - doctest WorkOS - - describe "#host/1" do - test "returns the configured host value" do - assert WorkOS.host() == "api.workos.com" - end - end - - describe "#base_url/1" do - test "returns the configured base_url value" do - assert WorkOS.base_url() == "https://api.workos.com" - end - end - - describe "#adapter/1" do - test "returns the configured adapter value" do - assert WorkOS.adapter() == Tesla.Mock - end - end - - describe "#api_key/1" do - test "returns the configured api_key value by default" do - assert WorkOS.api_key() == "sk_TEST" - end - - test "overrides the configured api_key value with a value from opts" do - assert WorkOS.api_key(api_key: "sk_OTHER") == "sk_OTHER" - end - end - - describe "#client_id/1" do - test "returns the configured client_id value by default" do - assert WorkOS.client_id() == "project_TEST" - end - - test "overrides the configured client_id value with a value from opts" do - assert WorkOS.client_id(client_id: "project_OTHER") == "project_OTHER" - end - end end diff --git a/workos_elixir.livemd b/workos_elixir.livemd new file mode 100644 index 0000000..f8e76ce --- /dev/null +++ b/workos_elixir.livemd @@ -0,0 +1,856 @@ +# WorkOS + Elixir + +```elixir +Mix.install([ + {:workos, "~> 1.0.0"}, + {:kino, "~> 0.9.4"} +]) +``` + +## Create a client + +To start using WorkOS, create a `client` with the API key and client ID that you copy via the WorkOS Dashboard: + +```elixir +client = WorkOS.client(api_key: System.fetch_env!("WORKOS_API_KEY"), client_id: System.fetch_env!("WORKOS_CLIENT_ID")) +``` + +Note that if you choose to configure WorkOS in your app config, passing a client struct is always optional. + +## API + +We've created some inputs below to be used along the API calls. Feel free to replace these as needed! + +### Single Sign-On + +#### Get Authorization URL + +Generate an OAuth 2.0 authorization URL. + +```elixir +provider = Kino.Input.text("Provider", default: "GoogleOAuth") |> Kino.render() + +redirect_uri = + Kino.Input.text("RedirectUri", default: "example.com/sso/workos/callback") |> Kino.render() + +client_id = Kino.Input.text("ClientID", default: "project_12345") |> Kino.render() + +Kino.nothing() +``` + +```elixir +{:ok, url} = + WorkOS.SSO.get_authorization_url(%{ + provider: Kino.Input.read(provider), + redirect_uri: Kino.Input.read(redirect_uri), + client_id: Kino.Input.read(client_id) + }) +``` + +#### Get a Profile and Token + +Get an access token along with the user [Profile](https://workos.com/docs/reference/sso/profile) using the `code` passed to your [Redirect URI](https://workos.com/docs/reference/sso/redirect-uri). + +```elixir +code = Kino.Input.text("Code") |> Kino.render() + +Kino.nothing() +``` + +```elixir +{:ok, %WorkOS.SSO.ProfileAndToken{access_token: access_token, profile: profile}} = + WorkOS.SSO.get_profile_and_token(client, Kino.Input.read(code)) +``` + +#### Get a User Profile + +Exchange an access token for a user’s [Profile](https://workos.com/docs/reference/sso/profile). + +```elixir +access_token = Kino.Input.text("Access Token") |> Kino.render() + +Kino.nothing() +``` + +```elixir +{:ok, %WorkOS.SSO.Profile{id: id}} = + WorkOS.SSO.get_profile(client, Kino.Input.read(access_token)) +``` + +#### Get a Connection + +Get the details of an existing [Connection](https://workos.com/docs/reference/sso/connection). + +```elixir +connection_id = Kino.Input.text("Connection ID") |> Kino.render() + +Kino.nothing() +``` + +```elixir +{:ok, %WorkOS.SSO.Connection{id: id}} = + WorkOS.SSO.get_connection(client, Kino.Input.read(connection_id)) +``` + +#### List Connections + +Get a list of all of your existing connections matching the criteria specified. + +```elixir +{:ok, %WorkOS.List{}} = + WorkOS.SSO.list_connections(client) +``` + +#### Delete a Connection + +Delete an existing connection. + +```elixir +connection_id = Kino.Input.text("Connection ID") |> Kino.render() + +Kino.nothing() +``` + +```elixir +{:ok, %WorkOS.Empty{}} = WorkOS.SSO.delete_connection(client, Kino.Input.read(connection_id)) +``` + +### Organizations + +#### List Organizations + +Get a list of all of your existing organizations matching the criteria specified. + +```elixir +{:ok, %WorkOS.List{}} = + WorkOS.Organizations.list_organizations(client) +``` + +#### Delete an Organization + +Deletes an organization in the current environment. + +```elixir +organization_id = Kino.Input.text("Organization ID") |> Kino.render() + +Kino.nothing() +``` + +```elixir +{:ok, %WorkOS.Empty{}} = WorkOS.Organizations.delete_organization(client, Kino.Input.read(organization_id)) +``` + +#### Get an Organization + +Get the details of an existing organization. + +```elixir +organization_id = Kino.Input.text("Organization ID") |> Kino.render() + +Kino.nothing() +``` + +```elixir +{:ok, %WorkOS.Organizations.Organization{id: id}} = + WorkOS.Organizations.get_organization(client, Kino.Input.read(organization_id)) +``` + +#### Create an Organization + +Creates a new organization in the current environment. + +```elixir +name = Kino.Input.text("Name") |> Kino.render() + +Kino.nothing() +``` + +```elixir +{:ok, %WorkOS.Organizations.Organization{id: id, name: name}} = + WorkOS.Organizations.create_organization(client, %{ + name: Kino.Input.read(name), + allow_profiles_outside_organization: false + }) +``` + +#### Update an Organization + +Updates an organization in the current environment. + +```elixir +name = Kino.Input.text("Name") |> Kino.render() +organization = Kino.Input.text("Organization ID") |> Kino.render() + +Kino.nothing() +``` + +```elixir +{:ok, %WorkOS.Organizations.Organization{id: id, name: name}} = + WorkOS.Organizations.update_organization(client, %{ + name: Kino.Input.read(name), + organization: Kino.Input.read(organization) + }) +``` + +### Admin Portal + +#### Generate a Portal Link + +Generate a Portal Link scoped to an Organization. + +```elixir +organization = Kino.Input.text("Organization") |> Kino.render() +intent = Kino.Input.text("Intent") |> Kino.render() + +Kino.nothing() +``` + +```elixir +{:ok, %WorkOS.Portal.Link{link: link}} = + WorkOS.Portal.generate_link(client, %{ + organization: Kino.Input.read(organization), + intent: Kino.Input.read(intent), + }) +``` + +### Directory Sync + +#### Get a Directory + +Get the details of an existing directory. + +```elixir +directory_id = Kino.Input.text("Directory ID") |> Kino.render() + +Kino.nothing() +``` + +```elixir +{:ok, %WorkOS.DirectorySync.Directory{id: id}} = + WorkOS.DirectorySync.get_directory(client, Kino.Input.read(directory_id)) +``` + +#### List Directories + +Get a list of all of your existing directories matching the criteria specified. + +```elixir +{:ok, %WorkOS.List{}} = + WorkOS.DirectorySync.list_directories(client) +``` + +#### Delete a Directory + +Delete an existing directory. + +```elixir +directory_id = Kino.Input.text("Directory ID") |> Kino.render() + +Kino.nothing() +``` + +```elixir +{:ok, %WorkOS.Empty{}} = WorkOS.DirectorySync.delete_directory(client, Kino.Input.read(directory_id)) +``` + +#### Get a Directory User + +Get the details of an existing Directory User. + +```elixir +directory_user_id = Kino.Input.text("Directory User ID") |> Kino.render() + +Kino.nothing() +``` + +```elixir +{:ok, %WorkOS.DirectorySync.Directory.User{id: id}} = + WorkOS.DirectorySync.get_user(client, Kino.Input.read(directory_user_id)) +``` + +#### List Directory Users + +Get a list of all of existing Directory Users matching the criteria specified. + +```elixir +directory_id = Kino.Input.text("Directory ID") |> Kino.render() + +Kino.nothing() +``` + +```elixir +{:ok, %WorkOS.List{}} = + WorkOS.DirectorySync.list_users(client, %{ + directory: Kino.Input.read(directory_id), + }) +``` + +#### Get a Directory Group + +Get the details of an existing Directory Group. + +```elixir +directory_group_id = Kino.Input.text("Directory Group ID") |> Kino.render() + +Kino.nothing() +``` + +```elixir +{:ok, %WorkOS.DirectorySync.Directory.Group{id: id}} = + WorkOS.DirectorySync.get_group(client, Kino.Input.read(directory_group_id)) +``` + +#### List Directory Groups + +Get a list of all of existing directory groups matching the criteria specified. + +```elixir +directory_id = Kino.Input.text("Directory ID") |> Kino.render() + +Kino.nothing() +``` + +```elixir +{:ok, %WorkOS.List{}} = + WorkOS.DirectorySync.list_groups(client, %{ + directory: Kino.Input.read(directory_id), + }) +``` + +### Magic Link + +#### Create Passwordless Session + +Create a Passwordless Session for a Magic Link Connection. + +```elixir +email = Kino.Input.text("Email") |> Kino.render() + +Kino.nothing() +``` + +```elixir +{:ok, %WorkOS.Passwordless.Session{id: id, name: name}} = + WorkOS.Passwordless.create_session(client, %{ + type: "MagicLink", + email: Kino.Input.read(email) + }) +``` + +#### Email a Magic Link + +Email a user the Magic Link confirmation URL. + +```elixir +session_id = Kino.Input.text("Passwordless Session ID") |> Kino.render() + +Kino.nothing() +``` + +```elixir +{:ok, %WorkOS.Passwordless.Session.Send{}} = + WorkOS.Passwordless.send_session(client, Kino.Input.read(session_id)) +``` + +### Events + +#### List Events + +```elixir +{:ok, %WorkOS.List{}} = WorkOS.Events.list_events(client, %{}) +``` + +### Audit Logs + +#### Create Event + +Emits an Audit Log Event. + +```elixir +organization_id = Kino.Input.text("Organization ID") |> Kino.render() + +Kino.nothing() +``` + +```elixir +{:ok, %WorkOS.Empty{}} = WorkOS.Events.create_event(client, %{ + organization_id: organization_id, + event: %{ + "action" => "document.updated", + "occurred_at" => "2022-09-08T19:46:03.435Z", + "actor" => %{ + "id" => "user_1", + "name" => "Jon Smith", + "type" => "user" + }, + "targets" => [ + %{ + "id" => "document_39127", + "type" => "document" + } + ], + "context" => %{ + "location" => "192.0.0.8", + "user_agent" => "Firefox" + }, + "metadata" => %{ + "successful" => true + } + } +}) +``` + +#### Create Export + +Create an Audit Log Export. + +```elixir +organization_id = Kino.Input.text("Organization ID") |> Kino.render() +range_start = Kino.Input.text("Range Start") |> Kino.render() +range_end = Kino.Input.text("Range End") |> Kino.render() + +Kino.nothing() +``` + +```elixir +{:ok, %WorkOS.AuditLogs.Export{id: id}} = + WorkOS.AuditLogs.create_export(client, %{ + organization_id: Kino.Input.read(organization_id), + range_start: Kino.Input.read(range_start), + range_end: Kino.Input.read(range_end), + }) +``` + +#### Get Export + +Get an Audit Log Export. + +```elixir +audit_log_export_id = Kino.Input.text("Audit Log Export ID") |> Kino.render() + +Kino.nothing() +``` + +```elixir +{:ok, %WorkOS.AuditLogs.Export{id: id}} = + WorkOS.AuditLogs.get_export(client, audit_log_export_id) +``` + +### User Management + +#### Get Authorization URL + +Generate an OAuth 2.0 authorization URL. + +```elixir +provider = Kino.Input.text("Provider", default: "authkit") |> Kino.render() + +redirect_uri = + Kino.Input.text("RedirectUri", default: "example.com/sso/workos/callback") |> Kino.render() + +client_id = Kino.Input.text("ClientID", default: "project_12345") |> Kino.render() + +Kino.nothing() +``` + +```elixir +{:ok, url} = + WorkOS.UserManagement.get_authorization_url(%{ + provider: Kino.Input.read(provider), + redirect_uri: Kino.Input.read(redirect_uri), + client_id: Kino.Input.read(client_id) + }) +``` + +#### Authenticate with Password + +Authenticates a user with email and password. + +```elixir +email = Kino.Input.text("Email") |> Kino.render() + +password = Kino.Input.text("Password") |> Kino.render() + +Kino.nothing() +``` + +```elixir +{:ok, u%WorkOS.UserManagement.Authentication{user: user}} = + WorkOS.UserManagement.authenticate_with_password(client, %{ + email: Kino.Input.read(provider), + password: Kino.Input.read(redirect_uri) + }) +``` + +#### Authenticate an OAuth or SSO User + +Authenticate a user using OAuth or an organization's SSO connection. + +```elixir +code = Kino.Input.text("Code") |> Kino.render() + +Kino.nothing() +``` + +```elixir +{:ok, %WorkOS.UserManagement.Authentication{user: user}} = + WorkOS.UserManagement.authenticate_with_code(client, %{ + code: Kino.Input.read(code), + }) +``` + +#### Authenticate with Magic Auth + +Authenticates a user by verifying a one-time code sent to the user's email address by the Magic Auth Send Code endpoint. + +```elixir +code = Kino.Input.text("Code") |> Kino.render() + +email = Kino.Input.text("Email") |> Kino.render() + +Kino.nothing() +``` + +```elixir +{:ok, %WorkOS.UserManagement.Authentication{user: user}} = + WorkOS.UserManagement.authenticate_with_magic_auth(client, %{ + code: Kino.Input.read(code), + email: Kino.Input.read(email), + }) +``` + +#### Authenticate with Email Verification Code + +When Email Verification is required in your Environment and an authentication attempt is made by a user with an unverified email address, a one-time code will automatically be sent to the user's email address. The authenticate API returns an email verification required error, which contains a pending_authentication_token. + +```elixir +code = Kino.Input.text("Code") |> Kino.render() + +pending_authentication_code = Kino.Input.text("Pending Authentication Code") |> Kino.render() + +Kino.nothing() +``` + +```elixir +{:ok, %WorkOS.UserManagement.Authentication{user: user}} = + WorkOS.UserManagement.authenticate_with_email_verification(client, %{ + code: Kino.Input.read(code), + pending_authentication_code: Kino.Input.read(pending_authentication_code), + }) +``` + +#### Authenticate with MFA TOTP + +When an authentication attempt is made by a user with an MFA Factor enrolled, the user will be required to enter a time-based-one-time-password (TOTP) each time they authenticate. This is indicated by the MFA challenge error response when calling the authenticate API. + +```elixir +authentication_challenge_id = Kino.Input.text("Authentication Challenge ID") |> Kino.render() + +pending_authentication_code = Kino.Input.text("Pending Authentication Code") |> Kino.render() + +Kino.nothing() +``` + +```elixir +{:ok, %WorkOS.UserManagement.Authentication{user: user}} = + WorkOS.UserManagement.authenticate_with_totp(client, %{ + authentication_challenge_id: Kino.Input.read(authentication_challenge_id), + pending_authentication_code: Kino.Input.read(pending_authentication_code), + }) +``` + +#### Authenticate with Selected Organization + +When a user is a member of multiple organizations, they must choose which organization to sign into as part of authentication. The organization selection error is returned when this happens. + +```elixir +authentication_challenge_id = Kino.Input.text("Authentication Challenge ID") |> Kino.render() + +organization_id = Kino.Input.text("Organization ID") |> Kino.render() + +Kino.nothing() +``` + +```elixir +{:ok, %WorkOS.UserManagement.Authentication{user: user}} = + WorkOS.UserManagement.authenticate_with_selected_organization(client, %{ + authentication_challenge_id: Kino.Input.read(authentication_challenge_id), + organization_id: Kino.Input.read(organization_id), + }) +``` + +#### Send Magic Auth Code + +Creates a one-time Magic Auth code and emails it to the user. + +```elixir +email = Kino.Input.text("Email") |> Kino.render() + +Kino.nothing() +``` + +```elixir +:ok = + WorkOS.UserManagement.send_magic_auth_code(client, Kino.Input.read(email)) +``` + +#### Enroll Auth Factor + +Enrolls a user in a new Factor. + +```elixir +user_id = Kino.Input.text("User ID") |> Kino.render() + +Kino.nothing() +``` + +```elixir +{:ok, %WorkOS.UserManagement.MultiFactor.EnrollAuthFactor{challenge: challenge, factor: factor}} = + WorkOS.UserManagement.enroll_auth_factor(client, Kino.Input.read(user_id), %{ + type: "totp", + }) +``` + +#### List Auth Factors + +Lists the Auth Factors for a user. + +```elixir +{:ok, %WorkOS.List{}} = + WorkOS.UserManagement.list_auth_factors(client) +``` + +#### Send Verification Email + +Sends a verification email to the provided user. The verification email will contain a one-time code which can be used to complete the email verification process. + +```elixir +user_id = Kino.Input.text("User ID") |> Kino.render() + +Kino.nothing() +``` + +```elixir +{:ok, %WorkOS.UserManagement.EmailVerification.SendVerificationEmail{user: user}} = + WorkOS.UserManagement.send_verification_email(client, Kino.Input.read(user_id)) +``` + +#### Verify Email Code + +Verifies user email using one-time code that was sent to the user. + +```elixir +user_id = Kino.Input.text("User ID") |> Kino.render() + +code = Kino.Input.text("Code") |> Kino.render() + +Kino.nothing() +``` + +```elixir +{:ok, %WorkOS.UserManagement.EmailVerification.VerifyEmail{user: user}} = + WorkOS.UserManagement.verify_email(client, Kino.Input.read(user_id), %{ + code: Kino.Input.read(code), + }) +``` + +#### Send Password Reset Email + +Sends a password reset email to a user. + +```elixir +email = Kino.Input.text("Email") |> Kino.render() + +password_reset_url = Kino.Input.text("Password Reset URL") |> Kino.render() + +Kino.nothing() +``` + +```elixir +:ok = WorkOS.UserManagement.send_password_reset_email(client, %{ + email: Kino.Input.read(email), + password_reset_url: Kino.Input.read(password_reset_url), + }) +``` + +#### Complete Password Reset + +Resets user password using token that was sent to the user. + +```elixir +email = Kino.Input.text("Email") |> Kino.render() + +password_reset_url = Kino.Input.text("Password Reset URL") |> Kino.render() + +Kino.nothing() +``` + +```elixir +:ok = WorkOS.UserManagement.reset_password(client, %{ + token: Kino.Input.read(token), + new_password: Kino.Input.read(new_password), + }) +``` + +#### Get an Organization Membership + +Get the details of an existing Organization Membership. + +```elixir +organization_membership_id = Kino.Input.text("Organization Membership ID") |> Kino.render() + +Kino.nothing() +``` + +```elixir +:ok = WorkOS.UserManagement.get_organization_membership(client, Kino.Input.read(organization_membership_id)) +``` + +#### List Organization Memberships + +Get a list of all of your existing Organization Memberships matching the criteria specified. + +```elixir +{:ok, %WorkOS.List{}} = + WorkOS.UserManagement.list_organization_memberships(client) +``` + +#### Create an Organization Membership + +Get a list of all of your existing Organization Memberships matching the criteria specified. + +```elixir +user_id = Kino.Input.text("User ID") |> Kino.render() + +organization_id = Kino.Input.text("Organization ID") |> Kino.render() + +Kino.nothing() +``` + +```elixir +{:ok, %WorkOS.List{}} = + WorkOS.UserManagement.create_organization_membership(client, %{ + user_id: Kino.Input.read(user_id), + organization_id: Kino.Input.read(organization_id), + }) +``` + +#### Delete an Organization Membership + +Deletes an existing Organization Membership. + +```elixir +organization_membership_id = Kino.Input.text("Organization Membership ID") |> Kino.render() + +Kino.nothing() +``` + +```elixir +{:ok, %WorkOS.Empty{}} = WorkOS.UserManagement.delete_organization_membership(client, Kino.Input.read(organization_membership_id)) +``` + +#### Get an Invitation + +Get the details of an existing Invitation. + +```elixir +invitation_id = Kino.Input.text("Invitation ID") |> Kino.render() + +Kino.nothing() +``` + +```elixir +:ok = WorkOS.UserManagement.get_invitation(client, Kino.Input.read(invitation_id)) +``` + +#### List Invitations + +Get a list of all of your existing Invitations matching the criteria specified. + +```elixir +{:ok, %WorkOS.List{}} = + WorkOS.UserManagement.list_invitations(client) +``` + +#### Send an Invitation + +Sends an Invitation to a recipient. + +```elixir +email = Kino.Input.text("Email") |> Kino.render() + +Kino.nothing() +``` + +```elixir +{:ok, %WorkOS.UserManagement.Invitation{id: id}} = + WorkOS.UserManagement.send_invitation(client, %{ + email: Kino.Input.read(email), + }) +``` + +#### Revoke an Invitation + +Revokes an existing Invitation. + +```elixir +invitation_id = Kino.Input.text("Invitation ID") |> Kino.render() + +Kino.nothing() +``` + +```elixir +{:ok, %WorkOS.UserManagement.Invitation{id: id}} = + WorkOS.UserManagement.revoke_invitation(client, Kino.Input.read(invitation_id)) +``` + +### Organization Domains + +#### Get an Organization Domain + +Get the details of an existing organization. + +```elixir +organization_domain_id = Kino.Input.text("Organization Domain ID") |> Kino.render() + +Kino.nothing() +``` + +```elixir +:ok = WorkOS.OrganizationDomains.get_organization_domain(client, Kino.Input.read(organization_domain_id)) +``` + +#### Create an Organization Domain + +Creates a new Organization Domain. + +```elixir +organization_id = Kino.Input.text("Organization ID") |> Kino.render() + +domain = Kino.Input.text("Domain") |> Kino.render() + +Kino.nothing() +``` + +```elixir +{:ok, %WorkOS.List{}} = + WorkOS.OrganizationDomains.create_organization_domain(client, %{ + organization_id: Kino.Input.read(organization_id), + domain: Kino.Input.read(domain), + }) +``` + +#### Verify an Organization Domain + +Initiates verification process for an Organization Domain. + +```elixir +organization_domain_id = Kino.Input.text("Organization Domain ID") |> Kino.render() + +Kino.nothing() +``` + +```elixir +{:ok, %WorkOS.List{}} = + WorkOS.OrganizationDomains.verify_organization_domain(client, Kino.Input.read(organization_domain_id)) +```