Skip to content
Open
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
69 changes: 66 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,10 +144,73 @@ Requires connecting with a user with appropriate rights.
More info [here](https://docs.microsoft.com/en-us/dotnet/framework/data/adonet/sql/snapshot-isolation-in-sql-server).


## Federation Authentication
## Federated Authentication

Azure SQL Database and Microsoft Fabric SQL endpoints accept a Microsoft Entra ID
access token instead of a username and password. Acquire the token yourself
(scope `https://database.windows.net/.default`) and pass it as `:access_token`.
Username and password are ignored, and encryption is required.

`:access_token` accepts a string, a zero-arity function or an `{module, function, args}`
tuple. Functions and MFAs are called on every connect and return the token,
`{:ok, token}` or `{:error, reason}`, so connections opened after the token expired get
a fresh one. If the function returns `{:error, reason}`, raises, throws, exits or takes
longer than `:connect_timeout` (default 15 seconds), the connect fails with a
`Tds.Error` and the pool retries with its usual backoff. A raised exception or an
`{:error, reason}` struct is reported by its module name only (an atom reason as is,
any other reason not at all), so secrets in it never reach the logs.

This Authentication mechanism is not supported.
This functionality requires specific environment to be developed.
```elixir
config :your_app, :tds_conn,
hostname: "xxxx.datawarehouse.fabric.microsoft.com",
database: "my_warehouse",
access_token: {MyApp.Entra, :fetch_sql_token, []},
ssl: true,
ssl_opts: [
verify: :verify_peer,
cacerts: :public_key.cacerts_get(),
server_name_indication: ~c"xxxx.datawarehouse.fabric.microsoft.com",
customize_hostname_check: [
match_fun: :public_key.pkix_verify_hostname_match_fun(:https)
]
]
```

When the server routes the login elsewhere (the Azure SQL gateway redirect), the driver
closes the gateway connection and connects to the routed host with the same options,
except that a pinned `server_name_indication` is replaced by the routed host name, so
the hostname check runs against the server that is actually reached.

Only the Security Token workflow is supported: the driver does not acquire tokens
itself (no ADAL/MSAL `FEDAUTHINFO` exchange).

### Known limitations

A connect can block indefinitely if the server stops responding during the TLS
handshake or before it answers LOGIN7: the handshake runs with an `:infinity` timeout
and the login response is read without one. This predates federated authentication
and applies to every login.

## Limiting response size

The driver buffers a whole response, across every result set, before decoding it.
Set `max_response_bytes: pos_integer()` to cap that buffer (anything else but `nil`
raises `ArgumentError` at `start_link`). The cap applies to query responses only, not
to the PRELOGIN, LOGIN7 and session setup exchanges of a connect. The count covers the
bytes read for one response, TDS packet headers included; over TLS these are the
decrypted bytes. Once it goes over the limit the driver stops reading, closes the
connection (the rest of the response is still on the wire) and the query returns:

```elixir
{:error, %Tds.ResponseTooLargeError{
limit: 10_000_000,
received: 10_002_432,
message: "response exceeded max_response_bytes (limit 10000000 bytes)"
}}
```

`received` is the number of bytes read when the driver stopped. The pool opens a new
connection for the next checkout. Without the option there is no limit.

## Data representation

Expand Down
24 changes: 24 additions & 0 deletions lib/tds.ex
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,25 @@ defmodule Tds do
| {:database, String.t()}
| {:username, String.t()}
| {:password, String.t()}
| {:access_token, access_token()}
| {:max_response_bytes, pos_integer()}
| {:timeout, timeout()}
| {:connect_timeout, timeout()}
| {:execution_mode, :prepare_execute | :executesql}
| DBConnection.start_option()

@typedoc """
A Microsoft Entra ID access token for federated authentication, or a
function / MFA returning `token`, `{:ok, token}` or `{:error, reason}` that is
called on every connect so pooled connections can pick up a refreshed token.
A function that fails, raises or runs past `:connect_timeout` fails the
connect, and the pool backs off and retries.
"""
@type access_token ::
String.t()
| (-> String.t() | {:ok, String.t()} | {:error, term})
| {module(), atom(), [term()]}

@type isolation_level ::
:read_uncommitted
| :read_committed
Expand Down Expand Up @@ -179,11 +193,21 @@ defmodule Tds do
end

defp default(opts) do
validate_max_response_bytes!(opts[:max_response_bytes])

opts
|> Keyword.put_new(:idle_timeout, @timeout)
|> Keyword.put_new(:execution_mode, @execution_mode)
end

defp validate_max_response_bytes!(nil), do: :ok
defp validate_max_response_bytes!(limit) when is_integer(limit) and limit > 0, do: :ok

defp validate_max_response_bytes!(limit) do
raise ArgumentError,
"expected :max_response_bytes to be a positive integer or nil, got: #{inspect(limit)}"
end

@doc """
Returns the configured JSON library.

Expand Down
21 changes: 19 additions & 2 deletions lib/tds/messages.ex
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ defmodule Tds.Messages do

# responses
defrecord :msg_preloginack, [:response]
defrecord :msg_loginack, [:redirect]
# features: FeatureIds the server acknowledged in FEATUREEXTACK
defrecord :msg_loginack, [:redirect, features: []]
defrecord :msg_prepared, [:params]
defrecord :msg_sql_result, [:columns, :rows, :row_count]
defrecord :msg_result, [:set, :params, :status]
Expand Down Expand Up @@ -79,6 +80,9 @@ defmodule Tds.Messages do
{:envchange, other}, {msg, s} ->
{msg, on_envchange(other, s)}

{:featureextack, features}, {msg_loginack() = msg, s} ->
{msg_loginack(msg, features: Enum.map(features, &elem(&1, 0))), s}

{:loginack, %{tds_version: version}}, {msg, s} ->
Process.put(:tds_version, version)
{msg, s}
Expand All @@ -93,8 +97,21 @@ defmodule Tds.Messages do
{:error, error}, _ ->
{msg_error(error: error), s}

{:fedauthinfo, _}, {msg_error(), _s} = msg_s ->
msg_s

{:fedauthinfo, _}, {_, s} ->
error = %{
line_number: 0,
number: 0,
msg_text:
"Server requested FEDAUTHINFO, which is not supported. " <>
"Pass a pre-acquired :access_token instead"
}

{msg_error(error: error), s}

_, msg ->
# FeatureExtAck should be processed here in future
msg
end)
end
Expand Down
Loading