@@ -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 }
0 commit comments