Msg.Auth (msg v0.3.8)

OAuth helper functions for Microsoft Identity Platform.

Provides utilities for the OAuth authorization code flow, enabling delegated permissions (user-context authentication) in addition to the client credentials flow (application-only authentication).

OAuth Flows

Client Credentials (Application-Only)

See Msg.Client.new/1 for application-only authentication using app permissions like User.ReadWrite.All, Group.ReadWrite.All.

Authorization Code (Delegated Permissions)

Use the functions in this module for delegated permissions like Calendars.ReadWrite.Shared which require user context:

  1. Generate authorization URL
  2. User signs in and grants consent
  3. Exchange authorization code for tokens
  4. Use refresh token to get new access tokens

Example

# Step 1: Generate authorization URL
credentials = %{
  client_id: "app-id",
  tenant_id: "tenant-id"
}

url = Msg.Auth.get_authorization_url(credentials,
  redirect_uri: "https://myapp.com/auth/callback",
  scopes: ["Calendars.ReadWrite.Shared", "offline_access"],
  state: "csrf-token"
)

# Step 2: Redirect user to URL, they sign in and approve
# Microsoft redirects back to: https://myapp.com/auth/callback?code=...&state=...

# Step 3: Exchange code for tokens
credentials = %{
  client_id: "app-id",
  client_secret: "secret",
  tenant_id: "tenant-id"
}

{:ok, tokens} = Msg.Auth.exchange_code_for_tokens(
  "authorization-code-here",
  credentials,
  redirect_uri: "https://myapp.com/auth/callback"
)

# Store tokens.refresh_token securely (encrypted in database)

# Step 4: Use refresh token to get new access tokens
{:ok, new_tokens} = Msg.Auth.refresh_access_token(
  tokens.refresh_token,
  credentials
)

# Create client with access token
client = Msg.Client.new(new_tokens.access_token)

Security Notes

  • Always use HTTPS for redirect URIs
  • Validate the state parameter to prevent CSRF attacks
  • Store refresh tokens encrypted in your database
  • Never log or commit tokens to version control
  • Handle token rotation (Microsoft may return new refresh tokens)

References

Summary

Functions

Exchanges an authorization code for access and refresh tokens.

Gets an access token using client credentials (application-only) flow.

Generates the Microsoft OAuth authorization URL for user sign-in.

Gets tokens using Resource Owner Password Credentials (ROPC) flow.

Refreshes an access token using a refresh token.

Types

credentials()

@type credentials() :: %{
  client_id: String.t(),
  client_secret: String.t(),
  tenant_id: String.t()
}

credentials_without_secret()

@type credentials_without_secret() :: %{client_id: String.t(), tenant_id: String.t()}

token_response()

@type token_response() :: %{
  access_token: String.t(),
  token_type: String.t(),
  expires_in: integer(),
  scope: String.t(),
  refresh_token: String.t()
}

Functions

exchange_code_for_tokens(code, credentials, opts)

@spec exchange_code_for_tokens(String.t(), credentials(), keyword()) ::
  {:ok, token_response()} | {:error, OAuth2.Response.t() | term()}

Exchanges an authorization code for access and refresh tokens.

After the user signs in and Microsoft redirects to your callback URL with an authorization code, call this function to exchange the code for tokens.

Parameters

  • code - Authorization code from Microsoft's callback
  • credentials - Map with :client_id, :client_secret, and :tenant_id
  • opts - Keyword list of options:
    • :redirect_uri (required) - Must match the URI used in authorization request

Returns

  • {:ok, token_response} - Map with access_token, refresh_token, expires_in, etc.
  • {:error, error} - OAuth error response

Examples

{:ok, tokens} = Msg.Auth.exchange_code_for_tokens(
  "authorization-code-from-callback",
  %{client_id: "app-id", client_secret: "secret", tenant_id: "tenant-id"},
  redirect_uri: "https://myapp.com/auth/callback"
)

# Store tokens.refresh_token securely (encrypted!)
# Use tokens.access_token to create client:
client = Msg.Client.new(tokens.access_token)

Error Handling

Common errors:

  • invalid_grant - Code expired or already used (codes expire in 10 minutes)
  • invalid_client - Invalid client_id or client_secret
  • redirect_uri_mismatch - redirect_uri doesn't match authorization request

get_app_token(map)

@spec get_app_token(credentials()) ::
  {:ok,
   %{access_token: String.t(), expires_in: integer(), token_type: String.t()}}
  | {:error, term()}

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

get_authorization_url(map, opts)

@spec get_authorization_url(
  credentials_without_secret(),
  keyword()
) :: String.t()

Generates the Microsoft OAuth authorization URL for user sign-in.

This is the first step in the authorization code flow. Redirect the user to this URL, where they will sign in and grant permissions. Microsoft will then redirect back to your redirect_uri with an authorization code.

Parameters

  • credentials - Map with :client_id and :tenant_id (no secret needed)
  • opts - Keyword list of options:
    • :redirect_uri (required) - HTTPS URL where Microsoft redirects after auth
    • :scopes (required) - List of permission scopes to request
    • :state (optional) - Random string for CSRF protection (recommended)
    • :prompt (optional) - Controls sign-in behavior. Values:
      • "select_account" - Shows account picker (use different account than current session)
      • "login" - Forces credential entry (no SSO)
      • "consent" - Shows consent dialog after sign-in
      • "none" - No interactive prompt (fails if interaction required)

Returns

Authorization URL string to redirect the user to.

Examples

url = Msg.Auth.get_authorization_url(
  %{client_id: "app-id", tenant_id: "tenant-id"},
  redirect_uri: "https://myapp.com/auth/callback",
  scopes: ["Calendars.ReadWrite.Shared", "Group.ReadWrite.All", "offline_access"],
  state: "random-csrf-token"
)

# Redirect user to this URL
# After sign-in, Microsoft redirects to:
# https://myapp.com/auth/callback?code=...&state=random-csrf-token

Important

  • Always include "offline_access" scope to receive a refresh token
  • Validate the state parameter in your callback to prevent CSRF attacks
  • The redirect URI must be registered in your Azure AD app configuration

get_tokens_via_password(map, opts)

@spec get_tokens_via_password(
  credentials(),
  keyword()
) :: {:ok, token_response()} | {:error, term()}

Gets tokens using Resource Owner Password Credentials (ROPC) flow.

WARNING: This flow is discouraged by Microsoft and should only be used for automated testing. It does not work with:

  • Accounts with MFA enabled
  • Federated/SSO accounts (e.g., Azure AD B2C)
  • Personal Microsoft accounts (only works with Azure AD work/school accounts)

Security Notes:

  • Never use in production - use authorization code flow instead
  • Test accounts should use strong passwords despite not having MFA
  • Rotate test account passwords regularly
  • Restrict test account permissions to minimum required

Parameters

  • credentials - Map with :client_id, :client_secret, :tenant_id
  • opts - Keyword list:
    • :username (required) - User's email/UPN
    • :password (required) - User's password
    • :scopes (optional) - List of scopes (defaults to Graph API default scope)

Returns

  • {:ok, token_response} - Map with access_token, refresh_token, expires_in, etc.
  • {:error, error} - OAuth error response

Examples

# For integration tests only
{:ok, tokens} = Msg.Auth.get_tokens_via_password(
  %{client_id: "...", client_secret: "...", tenant_id: "..."},
  username: "testuser@contoso.onmicrosoft.com",
  password: System.get_env("MICROSOFT_SYSTEM_USER_PASSWORD"),
  scopes: ["Calendars.ReadWrite.Shared", "Group.ReadWrite.All", "offline_access"]
)

# Use access token to create client
client = Msg.Client.new(tokens.access_token)

Azure AD Setup Requirements

  1. Create test user in Azure AD (e.g., testuser@yourtenant.onmicrosoft.com)
  2. Disable MFA for this test user
  3. In App Registration → Authentication → Advanced settings:
    • Set "Allow public client flows" to Yes
  4. Grant required permissions and admin consent
  5. Store credentials in .env (never commit to git)

Common Errors

  • AADSTS50126 - Invalid username or password
  • AADSTS7000218 - Public client flows not enabled (see setup step 3)
  • AADSTS50076 - MFA required (ROPC does not support MFA)
  • AADSTS700016 - Application not found in tenant

References

refresh_access_token(refresh_token, credentials, opts \\ [])

@spec refresh_access_token(String.t(), credentials(), keyword()) ::
  {:ok, token_response()} | {:error, OAuth2.Response.t() | term()}

Refreshes an access token using a refresh token.

Refresh tokens are long-lived and can be used to obtain new access tokens without requiring the user to sign in again. Call this function before the access token expires (typically every hour).

Parameters

  • refresh_token - Valid refresh token from a previous token exchange
  • credentials - Map with :client_id, :client_secret, and :tenant_id
  • opts - Keyword list of options:
    • :scopes (optional) - List of scopes to request (defaults to original scopes)

Returns

  • {:ok, token_response} - Map with new access_token, possibly new refresh_token, expires_in, etc.
  • {:error, error} - OAuth error response

Examples

{:ok, new_tokens} = Msg.Auth.refresh_access_token(
  stored_refresh_token,
  %{client_id: "app-id", client_secret: "secret", tenant_id: "tenant-id"}
)

# Microsoft may return a new refresh token (token rotation)
# Always update your stored refresh token:
if Map.has_key?(new_tokens, :refresh_token) do
  update_stored_refresh_token(new_tokens.refresh_token)
end

# Use new access token
client = Msg.Client.new(new_tokens.access_token)

Token Rotation

Microsoft may return a new refresh token in the response. Always check for refresh_token in the response and update your stored token if present.

Error Handling

Common errors:

  • invalid_grant - Refresh token expired or revoked (requires user to re-authenticate)
  • invalid_client - Invalid client credentials