Skip to content

Commit 282fac5

Browse files
committed
security fixes
1 parent 4bb317c commit 282fac5

2 files changed

Lines changed: 66 additions & 60 deletions

File tree

lib/console/chat/teams/auth.ex

Lines changed: 65 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -5,88 +5,93 @@ defmodule Console.Chat.Teams.Auth do
55
Follows the Bot Connector authentication spec:
66
https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-authentication
77
8-
It fetches (and caches) the connector OpenID metadata + JWKS, then validates the issuer, audience (the bot's
9-
Microsoft App ID), lifetime and RS256 signature. When a `:service_url` opt is supplied it is also checked
10-
against the `serviceurl` claim, since a valid token pins the service url the reply may be sent to.
8+
Teams authenticates channel -> bot webhooks with a signed OIDC JWT in the `Authorization` header - there is no
9+
hmac/shared-secret option like the scm/observability webhooks use - so we validate the token against the
10+
connector's published JWKS. We reuse `oidcc` (as the OIDC login flow does in `Console.Deployments.Settings`)
11+
to discover the metadata, load + cache the JWKS, and validate the signature/issuer/audience/lifetime. The
12+
Teams-specific `serviceurl` claim is checked separately.
1113
"""
12-
alias Console.Cache
14+
use Nebulex.Caching
1315
require Logger
1416

15-
@issuer "https://api.botframework.com"
16-
@metadata_url "https://login.botframework.com/v1/.well-known/openidconfiguration"
17-
@clock_skew 300
18-
@ttl :timer.hours(24)
17+
# The connector publishes its metadata here. Two quirks are needed to consume it via oidcc:
18+
# * the document's declared issuer is `https://api.botframework.com` (not this url) -> allow_issuer_mismatch
19+
# * the document omits several oidc-required fields (scopes/response_types/subject_types), so we backfill
20+
# them via document_overrides purely to satisfy the parser - they do not affect token validation.
21+
@config_issuer "https://login.botframework.com/v1"
22+
@cache_adapter Console.conf(:cache_adapter)
23+
@ttl :timer.hours(1)
24+
25+
@quirks %{
26+
quirks: %{
27+
allow_issuer_mismatch: true,
28+
document_overrides: %{
29+
"scopes_supported" => ["openid"],
30+
"response_types_supported" => ["id_token"],
31+
"subject_types_supported" => ["public"]
32+
}
33+
}
34+
}
1935

2036
@type claims :: map()
2137

38+
@doc """
39+
Verifies a Bot Framework JWT. `audience` must be the bot's Microsoft App ID (the connection's client id).
40+
Pass `service_url:` to additionally pin the token's `serviceurl` claim to the inbound activity's serviceUrl.
41+
"""
2242
@spec verify(binary, binary, keyword) :: {:ok, claims} | {:error, binary}
23-
def verify(token, audience, opts \\ []) when is_binary(token) and is_binary(audience) do
24-
with {:ok, header} <- Joken.peek_header(token),
25-
{:ok, jwk} <- signing_key(header),
26-
{:ok, claims} <- verify_signature(jwk, header, token),
27-
:ok <- validate_claims(claims, audience, opts) do
43+
def verify(token, audience, opts \\ [])
44+
def verify(token, audience, opts) when is_binary(token) and is_binary(audience) do
45+
with {:ok, _} <- peek(token),
46+
{:ok, {conf, jwks}} <- provider_configuration(),
47+
ctx = Oidcc.ClientContext.from_manual(conf, jwks, audience, "dummy_secret", %{client_jwks: JOSE.JWK.generate_key(16)}),
48+
validate_opts = %{signing_algs: ctx.provider_configuration.id_token_signing_alg_values_supported},
49+
{:ok, claims} <- validate_jwt(token, ctx, validate_opts),
50+
:ok <- validate_service_url(claims, opts[:service_url]) do
2851
{:ok, claims}
2952
end
3053
end
3154
def verify(_, _, _), do: {:error, "missing teams bot token or audience"}
3255

33-
defp verify_signature(jwk, %{"alg" => alg}, token) when is_binary(alg) do
34-
case JOSE.JWT.verify_strict(JOSE.JWK.from_map(jwk), [alg], token) do
35-
{true, %JOSE.JWT{fields: claims}, _} -> {:ok, claims}
36-
_ -> {:error, "invalid teams jwt signature"}
56+
@doc """
57+
Loads and caches the Bot Framework provider configuration + JWKS. Cached so we don't round-trip Microsoft on
58+
every webhook; the ttl also bounds how stale the signing keys can get across a key rotation.
59+
"""
60+
@decorate cacheable(cache: @cache_adapter, key: :teams_bf_oidc_config, opts: [ttl: @ttl], match: &ok?/1)
61+
def provider_configuration() do
62+
with {:ok, {conf, _}} <- Oidcc.ProviderConfiguration.load_configuration(@config_issuer, @quirks),
63+
{:ok, {jwks, _}} <- Oidcc.ProviderConfiguration.load_jwks(conf.jwks_uri) do
64+
{:ok, {conf, jwks}}
65+
else
66+
err ->
67+
Logger.warning("failed to load teams bot framework oidc configuration: #{inspect(err)}")
68+
{:error, "could not load teams bot framework configuration"}
3769
end
3870
end
39-
defp verify_signature(_, _, _), do: {:error, "teams jwt is missing a signing algorithm"}
4071

41-
defp validate_claims(claims, audience, opts) do
42-
now = System.system_time(:second)
43-
with :ok <- check(claims["iss"] == @issuer, "invalid teams jwt issuer"),
44-
:ok <- check(claims["aud"] == audience, "invalid teams jwt audience"),
45-
:ok <- check(is_integer(claims["exp"]) and claims["exp"] + @clock_skew > now, "teams jwt expired"),
46-
:ok <- check(!is_integer(claims["nbf"]) or claims["nbf"] - @clock_skew <= now, "teams jwt not yet valid"),
47-
:ok <- validate_service_url(claims, opts[:service_url]) do
48-
:ok
72+
# cheap, network-free rejection of obviously malformed tokens before we touch the provider config
73+
defp peek(token) do
74+
case Joken.peek_header(token) do
75+
{:ok, _} = ok -> ok
76+
_ -> {:error, "malformed teams jwt"}
4977
end
5078
end
5179

52-
defp validate_service_url(_claims, url) when not is_binary(url), do: :ok
53-
defp validate_service_url(%{"serviceurl" => claim}, url) when is_binary(claim),
54-
do: check(String.trim_trailing(claim, "/") == String.trim_trailing(url, "/"), "teams jwt serviceUrl mismatch")
55-
defp validate_service_url(_claims, _url), do: :ok
56-
57-
defp signing_key(%{"kid" => kid}) do
58-
case Enum.find(signing_keys(), & &1["kid"] == kid) do
59-
%{} = key -> {:ok, key}
60-
_ -> {:error, "no matching signing key for teams jwt"}
80+
defp validate_jwt(token, ctx, opts) do
81+
case Oidcc.Token.validate_jwt(token, ctx, opts) do
82+
{:ok, claims} -> {:ok, claims}
83+
{:error, err} -> {:error, "invalid teams jwt: #{inspect(err)}"}
6184
end
6285
end
63-
defp signing_key(_), do: {:error, "teams jwt is missing a key id"}
6486

65-
defp signing_keys() do
66-
case Cache.get(:teams_bf_jwks) do
67-
[_ | _] = keys -> keys
68-
_ -> refresh_keys()
69-
end
70-
end
71-
72-
defp refresh_keys() do
73-
with {:ok, %{"jwks_uri" => uri}} <- fetch(@metadata_url),
74-
{:ok, %{"keys" => keys}} <- fetch(uri) do
75-
Cache.put(:teams_bf_jwks, keys, ttl: @ttl)
76-
keys
77-
else
78-
err ->
79-
Logger.warning("failed to fetch teams bot framework jwks: #{inspect(err)}")
80-
[]
81-
end
82-
end
87+
# when no service_url is supplied there's nothing to pin against; otherwise the claim must be present and match.
88+
defp validate_service_url(_claims, url) when not is_binary(url), do: :ok
89+
defp validate_service_url(%{"serviceurl" => claim}, url) when is_binary(claim),
90+
do: check(String.trim_trailing(claim, "/") == String.trim_trailing(url, "/"), "teams jwt serviceUrl mismatch")
91+
defp validate_service_url(_claims, _url), do: {:error, "teams jwt is missing the serviceUrl claim"}
8392

84-
defp fetch(url) do
85-
case Req.get(url) do
86-
{:ok, %Req.Response{status: 200, body: body}} when is_map(body) -> {:ok, body}
87-
err -> {:error, err}
88-
end
89-
end
93+
defp ok?({:ok, _}), do: true
94+
defp ok?(_), do: false
9095

9196
defp check(true, _), do: :ok
9297
defp check(_, msg), do: {:error, msg}

test/console/chat/impl/teams_test.exs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ defmodule Console.Chat.Impl.TeamsTest do
6161
no_mention = activity(%{"entities" => [%{"type" => "mention", "mentioned" => %{"id" => "28:someone-else"}}]})
6262

6363
assert :ok = Teams.handle_activity(conn, no_mention)
64+
6465
assert [] = Repo.all(ChatbotMessage)
6566
end
6667

0 commit comments

Comments
 (0)