From 2d88d76fe1c5a94975e1d2da851f9c5070b85bd1 Mon Sep 17 00:00:00 2001 From: JohnnyT Date: Tue, 7 Oct 2025 05:41:31 -0700 Subject: [PATCH] Adds function for token lifecycle management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements get_app_token/1 to obtain application-only access tokens with metadata suitable for TokenManager implementations. Unlike Client.fetch_token!/1 which returns just the token string, this function returns {:ok, metadata} with access_token, expires_in, and token_type for accurate token expiration tracking. This enables TokenManager in MatMan to: - Track token expiry accurately using expires_in - Handle errors gracefully with {:ok, _} | {:error, _} pattern - Store tokens with computed expiration timestamps 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- lib/msg/auth.ex | 66 ++++++++++++++++++++++++++++++ test/msg/auth_test.exs | 7 ++++ test/msg/integration/auth_test.exs | 22 ++++++++++ 3 files changed, 95 insertions(+) diff --git a/lib/msg/auth.ex b/lib/msg/auth.ex index e45ab48..df3c925 100644 --- a/lib/msg/auth.ex +++ b/lib/msg/auth.ex @@ -314,6 +314,72 @@ defmodule Msg.Auth do end end + @doc """ + Gets an access token using client credentials (application-only) flow. + + This is similar to `Msg.Client.fetch_token!/1` but returns token metadata + for lifecycle management, making it suitable for TokenManager implementations. + + ## Parameters + + - `credentials` - Map with `:client_id`, `:client_secret`, and `:tenant_id` + + ## Returns + + - `{:ok, token_response}` - Map with access_token, expires_in, and token_type + - `{:error, error}` - OAuth error response + + ## Examples + + {:ok, token_info} = Msg.Auth.get_app_token(%{ + client_id: "app-id", + client_secret: "secret", + tenant_id: "tenant-id" + }) + + # Store token with accurate expiry + expires_at = DateTime.add(DateTime.utc_now(), token_info.expires_in, :second) + store_token(token_info.access_token, expires_at) + + ## Difference from `Msg.Client.fetch_token!/1` + + - Returns `{:ok, metadata}` instead of raising + - Includes `expires_in` for accurate lifecycle management + - Designed for token managers, not immediate client creation + """ + @spec get_app_token(credentials()) :: + {:ok, %{access_token: String.t(), expires_in: integer(), token_type: String.t()}} + | {:error, term()} + def get_app_token(%{client_id: client_id, client_secret: client_secret, tenant_id: tenant_id}) do + token_url = "https://login.microsoftonline.com/#{tenant_id}/oauth2/v2.0/token" + + params = [ + grant_type: "client_credentials", + client_id: client_id, + client_secret: client_secret, + scope: "https://graph.microsoft.com/.default" + ] + + headers = [{"content-type", "application/x-www-form-urlencoded"}] + body = URI.encode_query(params) + + case Req.post(token_url, headers: headers, body: body) do + {:ok, %{status: 200, body: response_body}} -> + {:ok, + %{ + access_token: response_body["access_token"], + token_type: response_body["token_type"], + expires_in: response_body["expires_in"] + }} + + {:ok, %{status: status, body: error_body}} -> + {:error, %{status: status, body: error_body}} + + {:error, error} -> + {:error, error} + end + end + # Private helpers defp maybe_add_state(params, nil), do: params diff --git a/test/msg/auth_test.exs b/test/msg/auth_test.exs index 61d2428..4a59440 100644 --- a/test/msg/auth_test.exs +++ b/test/msg/auth_test.exs @@ -91,4 +91,11 @@ defmodule Msg.AuthTest do assert function_exported?(Msg.Auth, :refresh_access_token, 2) end end + + describe "get_app_token/1" do + test "returns expected response shape" do + # Verify function signature + assert function_exported?(Msg.Auth, :get_app_token, 1) + end + end end diff --git a/test/msg/integration/auth_test.exs b/test/msg/integration/auth_test.exs index 0457586..a5653bd 100644 --- a/test/msg/integration/auth_test.exs +++ b/test/msg/integration/auth_test.exs @@ -190,4 +190,26 @@ defmodule Msg.Integration.AuthTest do assert String.contains?(tokens.scope, "Calendars.ReadWrite") end end + + describe "get_app_token/1" do + test "returns access token with metadata", %{credentials: credentials} do + {:ok, token_info} = Auth.get_app_token(credentials) + + assert is_binary(token_info.access_token) + assert token_info.token_type == "Bearer" + assert is_integer(token_info.expires_in) + assert token_info.expires_in > 0 + end + + test "returns error for invalid credentials" do + invalid_creds = %{ + client_id: "invalid-id", + client_secret: "invalid-secret", + tenant_id: "invalid-tenant" + } + + assert {:error, %{status: status}} = Auth.get_app_token(invalid_creds) + assert status == 400 + end + end end