# Task Recon: Implement token caching and refresh logic

**Task ID:** 6.3
**Status:** pending
**Generated:** 2026-01-28T12:00:00Z
**Agent:** claude-opus-4-5-20251101

---

## Task Summary

Integrate AuthService with CacheManager for token persistence and implement automatic token refresh when expired. This subtask adds the core token lifecycle management methods (`get_token` and `refresh_token`) that enable the CLI to acquire and cache OAuth 2.0 access tokens from Microsoft Entra ID via MSAL.

### Parent Task Context

**Parent Task:** Implement AuthService with MSAL (ID: 6)
**Parent Goal:** Create the AuthService for Microsoft Entra ID authentication supporting certificate and client-secret methods, with token caching and full login/logout/status lifecycle.
**This Subtask's Role:** Provides the token acquisition and caching foundation that subtask 6.4 (login/logout/status methods) will build upon. This is the critical bridge between MSAL authentication and the CacheManager persistence layer.

### Dependencies
- Depends on: 6.1 (certificate auth - DONE), 6.2 (client-secret auth - DONE)

### Acceptance Criteria
1. `get_token(tenant_name)` returns cached token if valid, or refreshes if expired/missing
2. `refresh_token(tenant_name)` acquires new token via MSAL and saves to cache
3. Token expiry buffer (5 minutes) triggers proactive refresh
4. MSAL errors (invalid_client, invalid_grant) raise AuthenticationError with remediation
5. CachedToken model persisted via CacheManager.save_token()

---

## Insights from Previous Tasks

### Dependency Task Notes
| Task ID | Title | Key Insight |
|---------|-------|-------------|
| 6.1 | Certificate auth setup | MSAL ConfidentialClientApplication makes network call in `__init__` - must mock in tests |
| 6.2 | Client-secret auth | MSAL accepts `client_credential` as either dict (cert) or string (secret) |
| 6.1 | Certificate auth setup | Authority URL format: `https://login.microsoftonline.com/{tenant_id}` |

### Reusable Patterns from Dependencies
- Certificate credential loading: Implemented in `auth_service.py:53-135` during Task 6.1
- Client secret retrieval: Implemented in `auth_service.py:137-160` during Task 6.2
- MSAL app creation: Pattern in `auth_service.py:162-217` with both auth methods

### Warnings and Gotchas
- **From Task 6.1**: MSAL `ConfidentialClientApplication` validates tenant at instantiation - unit tests must mock it
- **From Task 6.2**: Empty env var treated as not set (use `if not secret:` pattern)
- **From Task 6.1**: `cryptography` library is required at runtime, not just dev dependency

### Open Items Inherited
- [x] Subtask 6.1: Certificate auth (DONE)
- [x] Subtask 6.2: Client-secret auth (DONE)
- [ ] Subtask 6.3: Token caching with `get_token`, `refresh_token` (THIS TASK)
- [ ] Subtask 6.4: `login`, `logout`, `get_status`, `is_authenticated`

---

## Architecture Context

### Relevant Design Decisions
- **Token storage**: JSON files in `~/.teams-phone/tokens/{tenant_name}.json`
- **Token model**: `CachedToken` stores access_token, expires_at, tenant_id, scopes
- **Expiry check**: `CachedToken.is_expired()` compares current time to expires_at
- **Buffer time**: 5 minutes before expiry triggers proactive refresh (not yet implemented)
- **MSAL scope**: Fixed to `["https://graph.microsoft.com/.default"]` for client credentials flow

### Module Structure
```
src/teams_phone/
├── services/
│   └── auth_service.py     # ADD: get_token(), refresh_token()
├── infrastructure/
│   ├── cache_manager.py    # USE: get_token(), save_token()
│   └── config_manager.py   # USE: get_tenant(), get_default_tenant()
└── models/
    └── auth.py             # USE: CachedToken, AuthStatusType
```

### Conventions to Follow
- Tenant resolution: If `tenant_name` is None, use `config_manager.get_default_tenant()`
- Error handling: Raise `AuthenticationError` for credential issues, `ConfigurationError` for missing tenant
- Return types: `get_token()` returns `str` (access token), `refresh_token()` returns `AuthToken` model (needs creation)
- Type hints: Use `| None` union syntax, not `Optional[]`

---

## Related Code References

### Primary References (Must Read)
| File | Lines | Purpose |
|------|-------|---------|
| `src/teams_phone/services/auth_service.py` | 1-217 | Existing AuthService with _create_msal_app |
| `src/teams_phone/infrastructure/cache_manager.py` | 84-127 | get_token/save_token methods to use |
| `src/teams_phone/models/auth.py` | 24-55 | CachedToken model with is_expired() |
| `src/teams_phone/infrastructure/config_manager.py` | 120-138 | get_tenant() for profile resolution |

### Secondary References (Helpful Context)
| File | Lines | Purpose |
|------|-------|---------|
| `src/teams_phone/models/auth.py` | 58-79 | AuthStatus model (for 6.4, but shows patterns) |
| `src/teams_phone/exceptions.py` | 47-57 | AuthenticationError with remediation |
| `src/teams_phone/constants.py` | 1-94 | Exit codes and default paths |
| `.taskmaster/docs/architecture/7-core-workflows.md` | 1-35 | Authentication flow diagram |

### Code Patterns to Follow

```python
# Token retrieval pattern from cache_manager.py:84-108
def get_token(self, tenant_name: str) -> CachedToken | None:
    """Retrieve a cached token for a tenant."""
    token_path = self._get_token_path(tenant_name)
    if not token_path.exists():
        return None
    try:
        content = token_path.read_text(encoding="utf-8")
        return CachedToken.model_validate_json(content)
    except (PydanticValidationError, ValueError):
        return None
```

```python
# Token saving pattern from cache_manager.py:110-127
def save_token(self, tenant_name: str, token: CachedToken) -> None:
    """Save a token to the cache."""
    self.ensure_token_dir()
    token_path = self._get_token_path(tenant_name)
    content = token.model_dump_json()
    token_path.write_text(content, encoding="utf-8")
```

```python
# CachedToken.is_expired() from models/auth.py:44-55
def is_expired(self) -> bool:
    """Check if the token has expired."""
    now = datetime.now(timezone.utc)
    expires = self.expires_at
    if expires.tzinfo is None:
        expires = expires.replace(tzinfo=timezone.utc)
    return now >= expires
```

```python
# MSAL response handling pattern (from MSAL docs)
result = app.acquire_token_for_client(scopes=self.SCOPES)
if "access_token" in result:
    access_token = result["access_token"]
    expires_in = result["expires_in"]  # seconds
    expires_at = datetime.now(timezone.utc) + timedelta(seconds=expires_in)
else:
    # Error case
    error = result.get("error")
    error_description = result.get("error_description")
```

---

## Dependencies

### Internal Modules
- `teams_phone.infrastructure.CacheManager` - Token persistence (get_token, save_token)
- `teams_phone.infrastructure.ConfigManager` - Tenant profile lookup (get_tenant, get_default_tenant)
- `teams_phone.models.CachedToken` - Token storage model with is_expired()
- `teams_phone.models.TenantProfile` - Tenant configuration
- `teams_phone.exceptions.AuthenticationError` - For MSAL errors
- `teams_phone.exceptions.NotFoundError` - For missing tenant

### External Packages
- `msal` - ConfidentialClientApplication.acquire_token_for_client()
- `datetime` - datetime, timedelta, timezone for expiry calculations

### New Dependencies Needed
- **AuthToken model** - Return type for `refresh_token()`. May need to create in `models/auth.py` or reuse CachedToken with additional fields. Task details suggest creating AuthToken similar to CachedToken but for return values.

---

## Testing Guidance

### Test File Location
`tests/unit/test_auth_service.py` (extend existing file)

### Test Patterns in Use
```python
# Example test structure from tests/unit/test_auth_service.py:71-94
class TestAuthServiceInit:
    @pytest.mark.unit
    def test_accepts_dependencies(self, tmp_path: Path) -> None:
        config_manager = ConfigManager(config_path=str(tmp_path / "config.toml"))
        cache_manager = CacheManager(token_dir=str(tmp_path / "tokens"))
        service = AuthService(config_manager, cache_manager)
        assert service.config_manager is config_manager
```

```python
# MSAL mocking pattern from tests/unit/test_auth_service.py:254-284
with patch("teams_phone.services.auth_service.ConfidentialClientApplication") as mock_app:
    mock_instance = MagicMock()
    mock_app.return_value = mock_instance
    # Configure mock_instance.acquire_token_for_client() return value
```

### Fixtures Available
- `tmp_path` (pytest built-in) - Temporary directory for test files
- `monkeypatch` (pytest built-in) - For environment variable manipulation

### Test Utilities
- `tests/unit/test_auth_service.py:21-68` - `generate_test_certificate()` helper for cert tests
- `tests/unit/test_cache_manager.py:169-247` - Token save/get/round-trip test patterns

### Required Test Cases (from task details)
1. Cache hit returns cached token (non-expired)
2. Expired token triggers refresh
3. Missing token triggers refresh
4. MSAL error handling (invalid_client, invalid_grant)
5. Token save after successful acquisition
6. Buffer time refresh logic (5 min before expiry)
7. Tenant resolution (None -> default tenant)

---

## Risks and Blockers

### Technical Risks
- [ ] **AuthToken model undefined**: Task mentions returning `AuthToken` from `refresh_token()` but model doesn't exist. Need to create it or clarify if CachedToken should be returned.
- [ ] **Buffer time implementation**: Need to determine if buffer check goes in `get_token()` or `is_expired()`. Task suggests in `get_token()`.

### Dependency Risks
- [ ] **None**: Dependencies 6.1 and 6.2 are marked complete

### Open Questions
- [ ] Should `AuthToken` be a new model or can we reuse `CachedToken` for return value?
- [ ] Should buffer time (5 min) be a constant in `constants.py` or hardcoded?
- [ ] Should `get_token()` auto-login if no cached token exists, or raise an error?

---

## Implementation Recommendations

### Suggested Approach
1. Add `TOKEN_EXPIRY_BUFFER_SECONDS = 300` constant (5 minutes)
2. Create `AuthToken` model in `models/auth.py` if needed (or decide to return `CachedToken`)
3. Implement `_resolve_tenant(tenant_name: str | None) -> TenantProfile` helper
4. Implement `refresh_token(tenant_name)` - core MSAL token acquisition
5. Implement `get_token(tenant_name)` - cache check with buffer, calls refresh_token
6. Add comprehensive unit tests for all scenarios
7. Update `services/__init__.py` exports if AuthToken model created

### Files to Create/Modify
| Action | File | Reason |
|--------|------|--------|
| Modify | `src/teams_phone/services/auth_service.py` | Add get_token, refresh_token, _resolve_tenant |
| Modify | `src/teams_phone/models/auth.py` | Add AuthToken model (if needed) |
| Modify | `src/teams_phone/models/__init__.py` | Export AuthToken (if created) |
| Modify | `src/teams_phone/constants.py` | Add TOKEN_EXPIRY_BUFFER_SECONDS |
| Modify | `tests/unit/test_auth_service.py` | Add TestGetToken, TestRefreshToken classes |

### Quick Wins
- Start with `_resolve_tenant()` helper - simple config lookup logic
- `refresh_token()` is straightforward MSAL call + cache save
- `get_token()` builds on top of refresh_token with cache check

---

## Cheat Sheet

### Key Imports
```python
from datetime import datetime, timedelta, timezone
from teams_phone.exceptions import AuthenticationError, NotFoundError
from teams_phone.models import CachedToken, TenantProfile
```

### Key Functions to Use
- `self.config_manager.get_tenant(name)` - Get tenant profile by name
- `self.config_manager.get_default_tenant()` - Get default tenant name (or None)
- `self.cache_manager.get_token(tenant_name)` - Get cached token (or None)
- `self.cache_manager.save_token(tenant_name, token)` - Save CachedToken
- `self._create_msal_app(profile)` - Create MSAL app (already implemented)
- `CachedToken.is_expired()` - Check if token is expired

### Common Patterns
- Tenant resolution: `auth_service.py` (to be added)
- MSAL token acquisition: See 7-core-workflows.md lines 20-30
- Error remediation: See `exceptions.py:47-57`

### MSAL Response Format
```python
# Success response
{
    "access_token": "eyJ0eXAi...",
    "token_type": "Bearer",
    "expires_in": 3600  # seconds
}

# Error response
{
    "error": "invalid_client",
    "error_description": "AADSTS7000215: Invalid client secret..."
}
```

### Buffer Time Logic
```python
# Check if token needs refresh (expired OR within buffer)
def _needs_refresh(self, token: CachedToken) -> bool:
    if token.is_expired():
        return True
    # Check buffer time
    buffer = timedelta(seconds=TOKEN_EXPIRY_BUFFER_SECONDS)
    now = datetime.now(timezone.utc)
    expires = token.expires_at
    if expires.tzinfo is None:
        expires = expires.replace(tzinfo=timezone.utc)
    return now >= (expires - buffer)
```

---

*Generated by task-recon (claude-opus-4-5-20251101) | Ready for implementation*
