Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@
- Raise on an option the endpoint does not have. A misspelled `:resolution` or `:fields` used to be dropped silently, and the request went out without it.
- `Discovergy.WebsiteAccessCode.generate/2` returns the access code as the API sends it. It used to be decoded as a query string, and the first key of the resulting map was returned as the code.
- An unsuccessful response with an empty body is reported as `{:http_error, status}` instead of `:unknown`, which rendered as `":unknown"`.
- Replace the `:consumer` and `:token` fields of `Discovergy.Client` with a single `:credentials` field, read with `Discovergy.Client.credentials/1` and restored through the `:credentials` option of `Discovergy.Client.new/1`. A client authenticates one way or the other, and holding the pieces of an OAuth session in loose fields left no room for anything else. The struct is opaque, so the accessor also gets persistence out of the business of reaching into it.

```diff
- # persist client.consumer and client.token, then later:
- client = Discovergy.Client.new(consumer: consumer, token: token)
+ # persist Discovergy.Client.credentials(client), then later:
+ client = Discovergy.Client.new(credentials: credentials)
```

### Bug Fixes

Expand All @@ -32,11 +40,12 @@

### Security

- Redact the OAuth secrets from `Discovergy.Client`, `Discovergy.OAuth.Consumer` and `Discovergy.OAuth.Token` when they are inspected, so a Logger metadata field or a crash report no longer prints the credentials of the session.
- Redact the credentials of the session from `Discovergy.Client` and the structs it holds when they are inspected, so a Logger metadata field or a crash report no longer prints the OAuth secrets or the Basic auth password.

### Changes

- Document the quirks of the API: that HTTP Basic auth works and avoids the token lifecycle entirely, token expiry, the rate limits on consumer registration and authorization, the shape its errors arrive in, and the undocumented meter fields.
- Add `Discovergy.Client.basic_auth/3`, which authenticates with HTTP Basic auth instead of OAuth. Every endpoint accepts the account's email and password directly, so there is no token to expire, no consumer to register and neither of the rate limits `login/3` runs into, and no request is spent signing in. The API documents none of it, which is why this is an alternative to `login/3` rather than a replacement.
- Document the quirks of the API: token expiry, the rate limits on consumer registration and authorization, the shape its errors arrive in, and the undocumented meter fields.
- Add `Discovergy.Client.reauthorize/3`, which obtains a new access token while reusing the consumer registered by `login/3`. The API rate limits `consumer_token` requests and asks clients to reuse tokens, so an application that refreshed by calling `login/3` again would eventually be answered with a `429`.
- Add the `kwh_scaling_factor`, `printed_full_serial_number`, `storage_numbers` and `submeter` fields to `Discovergy.Meter`. The API returns them but they were silently dropped.
- Fix the `Discovergy.Measurement` typespec: `values` is a map, not a list of maps.
Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ iex> {:ok, client} = Discovergy.Client.reauthorize(client, email, password)

The API rate limits consumer registrations, so an application that renews its token by logging in again is eventually turned away with a `429`.

The API also accepts plain HTTP Basic auth, which `Discovergy.Client.basic_auth/3` sends. It skips the token lifecycle and the rate limits entirely, at the price of being undocumented:

```elixir
iex> client = Discovergy.Client.new() |> Discovergy.Client.basic_auth(email, password)
#Discovergy.Client<base_url: "https://api.inexogy.com/public/v1", ...>
```

Then pass the `client` to the respective endpoint function. For example, to list all meters the user has access to:

```elixir
Expand Down Expand Up @@ -92,7 +99,7 @@ iex> Discovergy.Measurements.get_last_reading(client, "c1972a89ce3a4d58aadcb7908

## Quirks of the API

The API behaves in ways its [official documentation](https://api.inexogy.com/docs/) does not cover: access tokens expire and come back as a `401` with an empty body, consumer registration and authorization are rate limited per IP, upstream errors arrive as HTML, and `/meters` returns fields that are documented nowhere. Plain HTTP Basic auth also works on every data endpoint, undocumented, which avoids the token lifecycle entirely.
The API behaves in ways its [official documentation](https://api.inexogy.com/docs/) does not cover: access tokens expire and come back as a `401` with an empty body, consumer registration and authorization are rate limited per IP, upstream errors arrive as HTML, and `/meters` returns fields that are documented nowhere. The Basic auth above is undocumented too.

[Quirks of the API](guides/api-quirks.md) collects what running against it turned up.

Expand Down
49 changes: 36 additions & 13 deletions guides/api-quirks.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,47 @@ running against it. Worth reading before deploying anything long-lived.

## HTTP Basic auth works, and is not documented anywhere

Every data endpoint accepts plain HTTP Basic auth with the account's email and
Every endpoint accepts plain HTTP Basic auth with the account's email and
password:

```
curl -u 'demo@inexogy.com:demo' https://api.inexogy.com/public/v1/meters
```

Verified against `meters`, `field_names`, `last_reading`, `devices` and
`statistics`, all returning `200`. Without credentials, or with a wrong
password, the same request is a `401`.
Verified against `meters`, `field_names`, `last_reading`, `readings`,
`statistics`, `devices`, `disaggregation` and `activities`, all returning
`200`. Without credentials the same request is a `401`, and the challenge it
comes back with names the scheme:

```
www-authenticate: Basic realm="Discovergy", charset="UTF-8"
```

This matters because it sidesteps everything below about tokens: nothing to
expire, no consumer to register, and neither of the rate limits. The
[ioBroker adapter](https://github.com/DrozmotiX/ioBroker.discovergy) has used
it exclusively for years.

This library uses OAuth 1.0a, which the official documentation describes as the
way in, and exposes no way to send Basic auth instead. Basic auth is
undocumented, so it carries the risk that anything undocumented does: it could
be withdrawn without notice. Everything below applies whenever OAuth is used,
which here is always.
`Discovergy.Client.basic_auth/3` sends it:

```elixir
client = Discovergy.Client.new() |> Discovergy.Client.basic_auth(email, password)
```

Nothing goes out when you call it, so a wrong password first shows up on the
next request, as a `401` that does say what is wrong:

```
401 Unauthorized: Invalid email or password supplied in the HTTP Authorization header
```

That is the whole difference in error handling: an expired OAuth token is an
empty-bodied `401` to be recovered from, while a `401` here is a credential
that will not start working on its own.

Basic auth is undocumented, so it carries the risk that anything undocumented
does: it could be withdrawn without notice. `Discovergy.Client.login/3` remains
the documented way in, and everything below applies whenever it is used.

## A public demo account exists

Expand Down Expand Up @@ -144,19 +164,22 @@ accounts does not lift them.

Use `Discovergy.Client.reauthorize/3` rather than `Discovergy.Client.login/3` to
renew a token. It reuses the consumer registered by the first login and never
calls `consumer_token`. The consumer can also be persisted and handed back to
`Discovergy.Client.new/1` so it survives a restart:
calls `consumer_token`. The session can also be persisted and handed back to
`Discovergy.Client.new/1` so it survives a restart, spending neither call:

```elixir
{:ok, client} = Discovergy.Client.new() |> Discovergy.Client.login(email, password)

# persist client.consumer and client.token, then later:
client = Discovergy.Client.new(consumer: consumer, token: token)
# persist Discovergy.Client.credentials(client), then later:
client = Discovergy.Client.new(credentials: credentials)
```

A persisted consumer expires like any other, so be ready for the
`:consumer_rejected` above and register a new one.

Basic auth is subject to neither limit, which is the other reason to reach for
it.

`authorize` is limited more tightly than `consumer_token`. Two calls in quick
succession from one address are enough to trigger it.

Expand Down
9 changes: 6 additions & 3 deletions lib/discovergy.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,12 @@ defmodule Discovergy do
Access tokens expire. Use `Discovergy.Client.reauthorize/3` to get a new one
rather than logging in again, so the consumer registered by `login/3` is
reused. Consumer registration is rate limited per IP, so an application that
renews its token by logging in again is eventually answered with a `429`. See
[Quirks of the API](api-quirks.md) for the rest of what running against it
turned up.
renews its token by logging in again is eventually answered with a `429`.

The API also accepts HTTP Basic auth, which `Discovergy.Client.basic_auth/3`
sends. It has no token to expire and no rate limit to run into, but it is
undocumented. See [Quirks of the API](api-quirks.md) for that and the rest of
what running against the API turned up.

Then pass the `client` to the respective endpoint function. For example, to list all meters the user has access to:

Expand Down
14 changes: 14 additions & 0 deletions lib/discovergy/basic_auth.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
defmodule Discovergy.BasicAuth do
@moduledoc false

@type t :: %__MODULE__{email: String.t(), password: String.t()}

@derive {Inspect, except: [:password]}
@enforce_keys [:email, :password]
defstruct [:email, :password]

@spec authorization(t) :: {String.t(), String.t()}
def authorization(%__MODULE__{email: email, password: password}) do
{"authorization", "Basic " <> Base.encode64("#{email}:#{password}")}
end
end
133 changes: 88 additions & 45 deletions lib/discovergy/client.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,36 +2,41 @@ defmodule Discovergy.Client do
@moduledoc """
A Discovergy API Client

Access tokens expire, and an expired one comes back as a `401` with an empty
body. Use `reauthorize/3` rather than `login/3` to get a new one: the API
rate limits consumer registration and asks clients to reuse tokens.
There are two ways to authenticate, and a client uses one of them for its
lifetime:

Consumers expire too. `reauthorize/3` then fails with
`reason: :consumer_rejected`, and `login/3` registers a new one.
- `login/3` runs the OAuth 1.0a flow the [official
documentation](https://api.inexogy.com/docs/) describes. Both the access
token and the consumer behind it expire, so a long-running client renews
them with `reauthorize/3` and, when that is refused, `login/3`.

- `basic_auth/3` sends the email and password with every request. Nothing
expires and nothing is rate limited, but the API documents no such thing.

See [Quirks of the API](api-quirks.md) for the behaviour this library has to
work around.
"""

alias Discovergy.{Config, Error, OAuth}
alias Discovergy.{BasicAuth, Config, Error, OAuth}

@base_url "https://api.inexogy.com/public/v1"
@user_agent "github.com/adriankumpf/discovergy"
@form_urlencoded "application/x-www-form-urlencoded"

@opaque credentials :: OAuth.t() | BasicAuth.t()

@opaque t :: %__MODULE__{
base_url: String.t(),
http_client: module,
consumer: OAuth.Consumer.t() | nil,
token: OAuth.Token.t() | nil
credentials: credentials | nil
}

# The client carries the OAuth secrets of the session. Keep them out of
# logs, crash reports and iex output.
# The client carries the credentials of the session. Keep them out of logs,
# crash reports and iex output.
@derive {Inspect, only: [:base_url]}

@enforce_keys [:base_url, :http_client]
defstruct [:base_url, :http_client, :consumer, :token]
defstruct [:base_url, :http_client, :credentials]

@doc """
Creates a new Discovergy API client.
Expand All @@ -41,15 +46,14 @@ defmodule Discovergy.Client do
- `:base_url` - the base URL for all endpoints (default: `#{@base_url}`)
- `:http_client` - a module implementing the `Discovergy.HTTPClient`
behaviour (default: the `:client` application environment setting)
- `:consumer` - the consumer of a previous session, taken from
`client.consumer` after a `login/3`
- `:token` - the access token of a previous session, taken from
`client.token`
- `:credentials` - the credentials of a previous session, as `credentials/1`
returns them

Passing `:consumer` and `:token` restores a session that was persisted
elsewhere, so a restart neither registers another consumer nor spends an
`authorize` call. Both are rate limited per IP. The client is then usable
right away, and `reauthorize/3` works on it once the token expires.
Passing `:credentials` restores a session that was persisted elsewhere. For
an OAuth session that means a restart neither registers another consumer nor
spends an `authorize` call, both of which are rate limited per IP. The client
is then usable right away, and `reauthorize/3` works on it once the token
expires. Basic auth has nothing worth restoring; call `basic_auth/3` instead.

## Examples

Expand All @@ -62,15 +66,37 @@ defmodule Discovergy.Client do
%__MODULE__{
base_url: opts[:base_url] || @base_url,
http_client: opts[:http_client] || Config.client(),
consumer: opts[:consumer],
token: opts[:token]
credentials: opts[:credentials]
}
end

@doc """
Returns the credentials of the session, or `nil` if the client has none.

Worth keeping for an OAuth session: handing it back to `new/1` after a
restart skips the two rate limited calls `login/3` would spend.

A Basic auth session is not worth keeping. `basic_auth/3` rebuilds it from
the same email and password, and storing it puts the account's password
somewhere new.

## Examples

iex> Discovergy.Client.new() |> Discovergy.Client.credentials()
nil

"""
@spec credentials(t) :: credentials | nil
def credentials(%__MODULE__{credentials: credentials}), do: credentials

@doc """
Authenticate with the Discovergy API using the email address and password of
the user.

This runs the OAuth 1.0a flow: it registers a consumer and exchanges the
credentials for an access token, both of which expire. Use `reauthorize/3` to
renew the token, or `basic_auth/3` to avoid the lifecycle altogether.

## Examples

iex> {:ok, client} = Discovergy.Client.new()
Expand All @@ -81,8 +107,8 @@ defmodule Discovergy.Client do
@spec login(t, String.t(), String.t()) :: {:ok, t} | {:error, Error.t()}
def login(%__MODULE__{} = client, email, password)
when is_binary(email) and is_binary(password) do
with {:ok, {consumer, token}} <- OAuth.login(client, email, password) do
{:ok, %__MODULE__{client | token: token, consumer: consumer}}
with {:ok, oauth} <- OAuth.login(client, email, password) do
{:ok, %__MODULE__{client | credentials: oauth}}
end
end

Expand All @@ -101,6 +127,9 @@ defmodule Discovergy.Client do
Named for what it does: the API has no credential-free refresh, so this needs
the user's password just as `login/3` does. Only the consumer is spared.

A client that has no OAuth session, because it never logged in or because it
uses `basic_auth/3`, fails with `reason: :not_logged_in`.

## When the consumer is gone

The API drops consumers too, usually at the nightly maintenance that expires
Expand All @@ -126,16 +155,40 @@ defmodule Discovergy.Client do

"""
@spec reauthorize(t, String.t(), String.t()) :: {:ok, t} | {:error, Error.t()}
def reauthorize(%__MODULE__{consumer: nil}, email, password)
def reauthorize(%__MODULE__{credentials: %OAuth{consumer: consumer}} = client, email, password)
when is_binary(email) and is_binary(password) do
with {:ok, oauth} <- OAuth.reauthorize(client, consumer, email, password) do
{:ok, %__MODULE__{client | credentials: oauth}}
end
end

def reauthorize(%__MODULE__{}, email, password)
when is_binary(email) and is_binary(password) do
{:error, %Error{reason: :not_logged_in}}
end

def reauthorize(%__MODULE__{consumer: consumer} = client, email, password)
@doc """
Authenticate with HTTP Basic auth, using the email address and password of
the user.

Every endpoint accepts the credentials directly, so there is no token to
expire, no consumer to register and neither of the rate limits `login/3` runs
into. Nothing is sent here: the client is ready to use, and a wrong password
surfaces as a `401` on the first request.

The API documents none of this, so it could be withdrawn without notice.
`login/3` is the documented way in.

## Examples

iex> Discovergy.Client.new() |> Discovergy.Client.basic_auth(email, password)
#Discovergy.Client<base_url: "https://api.inexogy.com/public/v1", ...>

"""
@spec basic_auth(t, String.t(), String.t()) :: t
def basic_auth(%__MODULE__{} = client, email, password)
when is_binary(email) and is_binary(password) do
with {:ok, token} <- OAuth.reauthorize(client, consumer, email, password) do
{:ok, %__MODULE__{client | token: token}}
end
%__MODULE__{client | credentials: %BasicAuth{email: email, password: password}}
end

@doc false
Expand All @@ -152,11 +205,10 @@ defmodule Discovergy.Client do

defp request(%__MODULE__{} = client, method, path, body, opts) do
url = build_url(client.base_url, path, opts[:query] || [])
consumer = Keyword.get(opts, :consumer, client.consumer)
token = Keyword.get(opts, :token, client.token)
credentials = Keyword.get(opts, :credentials, client.credentials)

headers =
sign(method, url, body, consumer, token) ++
authorization(credentials, method, url, body) ++
[{"user-agent", @user_agent} | content_type(method)]

client.http_client.request(
Expand All @@ -174,22 +226,13 @@ defmodule Discovergy.Client do
defp content_type(:post), do: [{"content-type", @form_urlencoded}]
defp content_type(_method), do: []

defp sign(_method, _url, _body, nil = _consumer, _token), do: []

defp sign(method, url, body, consumer, token) do
credentials =
OAuther.credentials(
consumer_key: consumer.key,
consumer_secret: consumer.secret,
token: token && token.oauth_token,
token_secret: token && token.oauth_token_secret
)
defp authorization(nil, _method, _url, _body), do: []

{header, _req_params} =
OAuther.sign(to_string(method), url, body, credentials) |> OAuther.header()
defp authorization(%BasicAuth{} = basic, _method, _url, _body),
do: [BasicAuth.authorization(basic)]

[header]
end
defp authorization(%OAuth{} = oauth, method, url, body),
do: [OAuth.authorization(oauth, method, url, body)]

# Optional parameters are passed as nil rather than dropped at every call
# site, because the API rejects the ones it does not expect to be empty.
Expand Down
Loading