# Task ID: 6

**Title:** Implement AuthService with MSAL

**Status:** pending

**Dependencies:** 3, 4, 5

**Priority:** high

**Description:** Create the AuthService for Microsoft Entra ID authentication supporting certificate and client-secret methods.

**Details:**

Create `src/teams_phone/services/auth_service.py`:
```python
from msal import ConfidentialClientApplication

class AuthService:
    SCOPES = ["https://graph.microsoft.com/.default"]
    
    def __init__(self, config_manager: ConfigManager, cache_manager: CacheManager): ...
    
    def login(self, tenant_name: str | None = None, force: bool = False) -> AuthToken: ...
    def logout(self, tenant_name: str | None = None, all_tenants: bool = False) -> None: ...
    def get_token(self, tenant_name: str | None = None) -> str: ...
    def get_status(self, tenant_name: str | None = None) -> AuthStatus: ...
    def refresh_token(self, tenant_name: str | None = None) -> AuthToken: ...
    def is_authenticated(self, tenant_name: str | None = None) -> bool: ...
    
    def _create_msal_app(self, profile: TenantProfile) -> ConfidentialClientApplication: ...
    def _load_certificate(self, cert_path: str) -> dict: ...
```

Certificate auth: load PEM file, extract thumbprint.
Client secret: read from TEAMS_PHONE_CLIENT_SECRET env var.
Cache tokens, refresh when expired.

**Test Strategy:**

Unit tests with mocked MSAL: test certificate loading, token acquisition, caching, refresh logic, error handling for invalid credentials.

---

## Subtask 6.1 Implementation Notes

### Session: 2026-01-28 22:00 - Agent: claude-sonnet-4-5-20250929

**Status:** Implemented

#### What Was Done
- Added `cryptography>=41.0.0` to `pyproject.toml` dependencies
- Created `src/teams_phone/services/auth_service.py` with:
  - `SCOPES` constant: `["https://graph.microsoft.com/.default"]`
  - `AuthService` class with `__init__(config_manager, cache_manager)`
  - `_load_certificate(cert_path)` method: loads PEM file, extracts private key and SHA-1 thumbprint
  - `_create_msal_app(profile)` method: creates MSAL ConfidentialClientApplication for certificate auth
- Updated `src/teams_phone/services/__init__.py` to export `AuthService` and `SCOPES`
- Created `tests/unit/test_auth_service.py` with 17 unit tests covering:
  - AuthService initialization
  - SCOPES constant validation
  - Certificate loading (valid, missing, permission errors, invalid formats)
  - MSAL app creation (certificate auth, missing cert_path, client-secret not yet implemented)

#### Key Decisions Made
- **SHA-1 thumbprint format**: Using uppercase hex (40 chars) as expected by MSAL and Azure portal
- **Type ignore for MSAL**: Added `# type: ignore[import-untyped]` since MSAL lacks py.typed marker
- **Combined PEM file**: `_load_certificate` expects a single PEM file containing both certificate and private key
- **No passphrase support**: Private keys must be unencrypted; encrypted keys raise AuthenticationError with remediation guidance
- **Client-secret deferred**: `_create_msal_app` raises "not yet implemented" for CLIENT_SECRET auth method (to be done in 6.2)

#### Patterns Discovered
- Certificate loading uses `cryptography.x509.load_pem_x509_certificate` for cert and `serialization.load_pem_private_key` for key
- MSAL certificate credential format: `{"private_key": pem_string, "thumbprint": hex_string_uppercase}`
- Authority URL format: `https://login.microsoftonline.com/{tenant_id}`
- Unit tests mock `ConfidentialClientApplication` to avoid network calls (MSAL validates tenant at instantiation)

#### Open Items for Next Agent
- [ ] Subtask 6.2: Implement client-secret authentication in `_create_msal_app`
- [ ] Subtask 6.3: Implement token caching with `get_token`, `refresh_token`
- [ ] Subtask 6.4: Implement `login`, `logout`, `get_status`, `is_authenticated`

#### Gotchas and Warnings
- MSAL `ConfidentialClientApplication` makes a network call to validate the tenant in `__init__` - unit tests must mock it
- The `cryptography` library is required at runtime, not just dev dependency
- Path expansion (`~`) is handled via `Path.expanduser()` in `_load_certificate`

---

## Subtask 6.2 Implementation Notes

### Session: 2026-01-28 - Agent: claude-sonnet-4-5-20250929

**Status:** Implemented

#### What Was Done
- Added `import os` to `auth_service.py` for environment variable access
- Added `CLIENT_SECRET_ENV_VAR = "TEAMS_PHONE_CLIENT_SECRET"` constant with docstring noting dev/test only usage
- Implemented `_get_client_secret() -> str` helper method:
  - Reads from `os.environ.get(CLIENT_SECRET_ENV_VAR)`
  - Raises `AuthenticationError` if env var is not set or is empty string
  - Includes remediation guidance with export command example
- Updated `_create_msal_app()` to handle `AuthMethod.CLIENT_SECRET`:
  - Added `elif` branch calling `_get_client_secret()`
  - Creates `ConfidentialClientApplication` with `client_credential=secret` (string)
  - Updated docstring to reflect both auth methods are now supported
- Updated `src/teams_phone/services/__init__.py` to export `CLIENT_SECRET_ENV_VAR`
- Replaced the "not yet implemented" test with 3 new tests:
  - `test_creates_app_with_client_secret_auth` - verifies MSAL app creation
  - `test_client_secret_raises_when_env_var_missing` - tests missing env var
  - `test_client_secret_raises_when_env_var_empty` - tests empty string rejection
- Added new `TestGetClientSecret` test class with 5 tests:
  - `test_reads_secret_from_env_var` - happy path
  - `test_raises_when_env_var_not_set` - None case
  - `test_raises_when_env_var_empty` - empty string case
  - `test_preserves_whitespace_in_secret` - ensures no trimming
  - `test_remediation_includes_instructions` - verifies helpful error message

#### Key Decisions Made
- **Empty string treated as not set**: Using `if not secret:` to reject both `None` and `""` - an empty env var is an error
- **Whitespace preserved**: Client secrets are used as-is without stripping, since they must match Azure AD exactly
- **Security documented**: Docstrings and remediation messages clearly state client-secret is for dev/test only
- **Defensive fallback**: Added unreachable `raise` at end of `_create_msal_app` for future-proofing if new auth methods are added

#### Patterns Discovered
- MSAL accepts `client_credential` as either a dict (certificate) or string (secret) - same parameter, different types
- `monkeypatch.setenv()` and `monkeypatch.delenv()` are the pytest-native way to manipulate env vars in tests
- Same authority URL format works for both auth methods: `https://login.microsoftonline.com/{tenant_id}`

#### Open Items for Next Agent
- [ ] Subtask 6.3: Implement token caching with `get_token`, `refresh_token`
- [ ] Subtask 6.4: Implement `login`, `logout`, `get_status`, `is_authenticated`

#### Gotchas and Warnings
- Client-secret auth is less secure than certificates - use only for dev/test
- Environment variable must be set before calling `_create_msal_app` with CLIENT_SECRET profile
- Tests use `monkeypatch` fixture for env vars to ensure isolation between tests

---

## Subtask 6.3 Implementation Notes

### Session: 2026-01-28 - Agent: claude-sonnet-4-5-20250929

**Status:** Implemented

#### What Was Done
- Added `TOKEN_EXPIRY_BUFFER_SECONDS = 300` constant to `constants.py` (5 minutes proactive refresh)
- Added imports to `auth_service.py`: `datetime`, `timedelta`, `timezone`, `TOKEN_EXPIRY_BUFFER_SECONDS`, `ConfigurationError`, `CachedToken`
- Implemented `_resolve_tenant(tenant_name: str | None) -> TenantProfile`:
  - Returns profile for explicit tenant name via `config_manager.get_tenant()`
  - Uses `config_manager.get_default_tenant()` when `tenant_name` is `None`
  - Raises `ConfigurationError` if no default configured and `None` passed
  - Raises `NotFoundError` if specified tenant doesn't exist
- Implemented `_needs_refresh(token: CachedToken) -> bool`:
  - Returns `True` if token is expired (via `token.is_expired()`)
  - Returns `True` if within 5-minute buffer period before expiry
  - Handles both naive and timezone-aware datetimes (assumes UTC for naive)
- Implemented `refresh_token(tenant_name: str | None = None) -> CachedToken`:
  - Resolves tenant via `_resolve_tenant()`
  - Creates MSAL app via `_create_msal_app()`
  - Calls `app.acquire_token_for_client(scopes=SCOPES)`
  - Calculates expiration from `expires_in` (defaults to 3600 seconds)
  - Creates and saves `CachedToken` via `cache_manager.save_token()`
  - Raises `AuthenticationError` with context-specific remediation on MSAL errors
- Implemented `_get_error_remediation(error, error_description, profile) -> str`:
  - Provides specific guidance for `invalid_client` (different for cert vs secret)
  - Provides guidance for `invalid_grant` (tenant/consent issues)
  - Provides guidance for `unauthorized_client` (app registration issues)
  - Generic fallback for unknown errors
- Implemented `get_token(tenant_name: str | None = None) -> str`:
  - Resolves tenant and checks cache via `cache_manager.get_token()`
  - Returns cached `access_token` if valid and not within buffer period
  - Calls `refresh_token()` to acquire new token if missing/expired/near-expiry
- Added 21 new unit tests across 4 new test classes:
  - `TestResolveTenant` (4 tests): explicit name, default tenant, no default error, not found error
  - `TestNeedsRefresh` (5 tests): expired, within buffer, valid, naive datetime, buffer value
  - `TestRefreshToken` (5 tests): acquires and caches, MSAL error, default tenant, expiration calc, invalid_grant
  - `TestGetToken` (7 tests): cache hit, refresh expired, refresh within buffer, no cache, default tenant, config error, not found

#### Key Decisions Made
- **Return `CachedToken` from `refresh_token()`**: Decided to return `CachedToken` rather than creating a separate `AuthToken` model since it contains all needed information
- **Buffer check in `_needs_refresh()`**: Separated buffer logic into helper method for testability
- **Resolve tenant first, then use profile.name for caching**: Ensures consistent tenant name resolution whether explicit or default
- **Default `expires_in` to 3600**: MSAL should always return this, but defensive default prevents crashes
- **Remediation varies by auth method for `invalid_client`**: Certificate issues differ from secret issues

#### Patterns Discovered
- MSAL response format: `{"access_token": "...", "expires_in": 3600}` on success
- MSAL error format: `{"error": "invalid_client", "error_description": "..."}`
- Buffer time comparison: `now >= (expires - buffer)` where buffer is `timedelta(seconds=300)`
- All tests mock `ConfidentialClientApplication` to avoid network calls

#### Open Items for Next Agent
- [ ] Subtask 6.4: Implement `login`, `logout`, `get_status`, `is_authenticated`

#### Gotchas and Warnings
- Naive datetimes in `expires_at` are assumed to be UTC - the code handles this but prefer aware datetimes
- `get_token()` returns only the access token string; use `refresh_token()` if you need the full `CachedToken`
- Cache key is the resolved tenant `profile.name`, not the original `tenant_name` parameter

---

## Subtask 6.4 Implementation Notes

### Session: 2026-01-28 - Agent: claude-sonnet-4-5-20250929

**Status:** Implemented

#### What Was Done
- Added import for `NotFoundError` and `AuthStatus`, `AuthStatusType` to `auth_service.py`
- Implemented `is_authenticated(tenant_name: str | None = None) -> bool`:
  - Resolves tenant via `_resolve_tenant()`
  - Checks for cached token via `cache_manager.get_token()`
  - Returns `True` only if token exists and `_needs_refresh()` returns `False`
  - **Never raises exceptions** - catches `ConfigurationError`, `NotFoundError`, `AuthenticationError` and returns `False`
- Implemented `get_status(tenant_name: str | None = None) -> AuthStatus`:
  - Resolves tenant and returns structured `AuthStatus` model
  - Returns `UNAUTHENTICATED` if no cached token exists
  - Returns `EXPIRED` if token's `is_expired()` returns `True`
  - Returns `AUTHENTICATED` with full details (tenant_name, tenant_id, expires_at) for valid tokens
  - Raises `ConfigurationError` or `NotFoundError` for invalid tenant (caller needs to fix config)
- Implemented `login(tenant_name: str | None = None, force: bool = False) -> CachedToken`:
  - Returns existing valid cached token if `force=False` and authenticated
  - Always acquires new token via `refresh_token()` when `force=True`
  - Acquires new token if no cache or token needs refresh
  - Raises appropriate errors from `_resolve_tenant()` and `refresh_token()`
- Implemented `logout(tenant_name: str | None = None, all_tenants: bool = False) -> None`:
  - Clears all tokens via `cache_manager.clear_tokens()` when `all_tenants=True`
  - Clears specific tenant token when `all_tenants=False`
  - When `all_tenants=True`, `tenant_name` parameter is ignored (doesn't raise for invalid tenant)
  - Raises `ConfigurationError` or `NotFoundError` for invalid tenant when `all_tenants=False`
- Added 25 new unit tests across 4 new test classes:
  - `TestIsAuthenticated` (7 tests): valid token, expired token, no token, within buffer, no throw for config error, no throw for not found, default tenant
  - `TestGetStatus` (6 tests): authenticated with details, unauthenticated, expired, config error, not found, default tenant
  - `TestLogin` (7 tests): cached token, force=True, not authenticated, cached expired, config error, not found, default tenant
  - `TestLogout` (7 tests): single tenant, all tenants, ignores tenant_name when all_tenants, config error, not found, default tenant, idempotent

#### Key Decisions Made
- **`is_authenticated` never throws**: Per acceptance criteria, catches all exceptions and returns `False` - safe for conditional checks
- **`get_status` throws for invalid tenant**: Unlike `is_authenticated`, this method throws because the caller needs to know about config issues
- **`login` returns `CachedToken`**: Following subtask 6.3 decision to use `CachedToken` instead of creating separate `AuthToken` model
- **`logout` with `all_tenants=True` skips tenant resolution**: Allows clearing all tokens without needing a valid tenant config
- **`get_status` uses `is_expired()` not `_needs_refresh()`**: Status shows actual expiration state, not whether proactive refresh is needed

#### Patterns Discovered
- All public methods follow same tenant resolution pattern via `_resolve_tenant()`
- `is_authenticated` is the only method that swallows exceptions - all others propagate them
- `CachedToken` is reused from subtask 6.3 - no new models needed
- Test patterns use `tmp_path` for isolated ConfigManager/CacheManager instances

#### Open Items for Next Agent
- Task 6 (parent) is now complete - all subtasks implemented
- AuthService is ready for use by CLI commands

#### Gotchas and Warnings
- `is_authenticated(None)` returns `False` if no default tenant configured - doesn't throw
- `get_status` distinguishes between EXPIRED (token expired) and UNAUTHENTICATED (no token) - buffer period doesn't affect status
- `logout(all_tenants=True)` clears ALL token files in the token directory, even for unconfigured tenants

---
