Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c7882adb24 | |||
| 9491d698e8 | |||
| 5b71ac3251 | |||
| 815a09be34 | |||
| c46f9eb212 | |||
| 28219e00e7 | |||
| daaf460b0c | |||
| 04f05f725c | |||
| b499aa2abe | |||
| 72df7dd1eb | |||
| 0af9657fea |
@@ -5,6 +5,28 @@ All notable changes to the Nextcloud MCP Server will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [PEP 440](https://peps.python.org/pep-0440/).
|
and this project adheres to [PEP 440](https://peps.python.org/pep-0440/).
|
||||||
|
|
||||||
|
## v0.62.0 (2026-01-26)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- **scripts**: add database query helpers for development
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **astrolabe**: resolve Psalm type errors in PDF preview code
|
||||||
|
- **astrolabe**: fix Psalm baseline and ESLint import order
|
||||||
|
- **astrolabe**: load pdfjs-dist externally to fix PDF viewer
|
||||||
|
- **astrolabe**: improve error messages for authorization issues
|
||||||
|
- **astrolabe**: rename OAuthController and fix app password check
|
||||||
|
- **tests**: improve Astrolabe integration test reliability
|
||||||
|
- **astrolabe**: update Plotly title attributes for v3 compatibility
|
||||||
|
- **deps**: update dependency plotly.js-dist-min to v3
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- **api**: split management.py into domain-focused modules
|
||||||
|
- **astrolabe**: replace client-side PDF.js with server-side PyMuPDF rendering
|
||||||
|
|
||||||
## v0.61.5 (2026-01-17)
|
## v0.61.5 (2026-01-17)
|
||||||
|
|
||||||
### Fix
|
### Fix
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[tool.commitizen]
|
[tool.commitizen]
|
||||||
name = "cz_conventional_commits"
|
name = "cz_conventional_commits"
|
||||||
version = "0.57.13"
|
version = "0.57.15"
|
||||||
tag_format = "nextcloud-mcp-server-$version"
|
tag_format = "nextcloud-mcp-server-$version"
|
||||||
version_scheme = "semver"
|
version_scheme = "semver"
|
||||||
update_changelog_on_bump = true
|
update_changelog_on_bump = true
|
||||||
|
|||||||
@@ -14,6 +14,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Configurable resource limits
|
- Configurable resource limits
|
||||||
- Grafana dashboard annotations
|
- Grafana dashboard annotations
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.15 (2026-01-26)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- **scripts**: add database query helpers for development
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **astrolabe**: resolve Psalm type errors in PDF preview code
|
||||||
|
- **astrolabe**: fix Psalm baseline and ESLint import order
|
||||||
|
- **astrolabe**: load pdfjs-dist externally to fix PDF viewer
|
||||||
|
- **astrolabe**: improve error messages for authorization issues
|
||||||
|
- **astrolabe**: rename OAuthController and fix app password check
|
||||||
|
- **tests**: improve Astrolabe integration test reliability
|
||||||
|
- **astrolabe**: update Plotly title attributes for v3 compatibility
|
||||||
|
- **deps**: update dependency plotly.js-dist-min to v3
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- **api**: split management.py into domain-focused modules
|
||||||
|
- **astrolabe**: replace client-side PDF.js with server-side PyMuPDF rendering
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.14 (2026-01-26)
|
||||||
|
|
||||||
## nextcloud-mcp-server-0.57.13 (2026-01-24)
|
## nextcloud-mcp-server-0.57.13 (2026-01-24)
|
||||||
|
|
||||||
## nextcloud-mcp-server-0.57.12 (2026-01-20)
|
## nextcloud-mcp-server-0.57.12 (2026-01-20)
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
|||||||
name: nextcloud-mcp-server
|
name: nextcloud-mcp-server
|
||||||
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
|
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
|
||||||
type: application
|
type: application
|
||||||
version: 0.57.13
|
version: 0.57.15
|
||||||
appVersion: "0.61.5"
|
appVersion: "0.62.0"
|
||||||
keywords:
|
keywords:
|
||||||
- nextcloud
|
- nextcloud
|
||||||
- mcp
|
- mcp
|
||||||
|
|||||||
@@ -0,0 +1,461 @@
|
|||||||
|
# Authentication Flows by Deployment Mode
|
||||||
|
|
||||||
|
This document provides a unified reference for authentication flows across all deployment modes. For configuration details, see [Authentication](authentication.md). For OAuth protocol details, see [OAuth Architecture](oauth-architecture.md).
|
||||||
|
|
||||||
|
## Quick Reference Matrix
|
||||||
|
|
||||||
|
| Mode | Client → MCP → NC | Background Sync | Astrolabe → MCP |
|
||||||
|
|------|-------------------|-----------------|-----------------|
|
||||||
|
| [Single-User BasicAuth](#1-single-user-basicauth) | Embedded credentials | Same credentials | N/A |
|
||||||
|
| [Multi-User BasicAuth](#2-multi-user-basicauth) | Header pass-through | App password (optional) | Bearer token |
|
||||||
|
| [OAuth Single-Audience](#3-oauth-single-audience-default) | Multi-audience token | Refresh token exchange | Bearer token |
|
||||||
|
| [OAuth Token Exchange](#4-oauth-token-exchange-rfc-8693) | RFC 8693 exchange | Refresh token exchange | Bearer token |
|
||||||
|
| [Smithery Stateless](#5-smithery-stateless) | Session parameters | Not supported | N/A |
|
||||||
|
|
||||||
|
## Communication Patterns
|
||||||
|
|
||||||
|
This document covers three distinct communication patterns:
|
||||||
|
|
||||||
|
1. **MCP Client → MCP Server → Nextcloud**: Interactive tool calls initiated by users through MCP clients (Claude Desktop, etc.)
|
||||||
|
2. **MCP Server → Nextcloud**: Background operations like vector sync that run without user interaction
|
||||||
|
3. **Astrolabe → MCP Server**: Nextcloud app backend communication for settings UI and unified search
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployment Modes
|
||||||
|
|
||||||
|
### 1. Single-User BasicAuth
|
||||||
|
|
||||||
|
**Use Case:** Personal Nextcloud instance, local development, single-user deployments.
|
||||||
|
|
||||||
|
#### MCP Client → MCP Server → Nextcloud
|
||||||
|
|
||||||
|
```
|
||||||
|
MCP Client MCP Server Nextcloud
|
||||||
|
│ │ │
|
||||||
|
│── MCP Request ─────────────▶│ │
|
||||||
|
│ (no auth required) │ │
|
||||||
|
│ │── HTTP + BasicAuth ───────▶│
|
||||||
|
│ │ Authorization: Basic │
|
||||||
|
│ │ (embedded credentials) │
|
||||||
|
│ │◀── API Response ───────────│
|
||||||
|
│◀── Tool Result ─────────────│ │
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key characteristics:**
|
||||||
|
- Credentials embedded in server configuration (`NEXTCLOUD_USERNAME`, `NEXTCLOUD_PASSWORD`)
|
||||||
|
- Single shared `NextcloudClient` created at startup
|
||||||
|
- No MCP-level authentication required (server trusts local clients)
|
||||||
|
- All requests use the same Nextcloud user
|
||||||
|
|
||||||
|
**Implementation:** `context.py:78-79` - Returns shared client from lifespan context
|
||||||
|
|
||||||
|
#### Background Sync
|
||||||
|
|
||||||
|
Uses the same embedded credentials as interactive requests. The background job accesses Nextcloud with the configured username/password.
|
||||||
|
|
||||||
|
**Implementation:** Background jobs use `get_settings()` to access credentials
|
||||||
|
|
||||||
|
#### Astrolabe Integration
|
||||||
|
|
||||||
|
Not applicable - Astrolabe is only used in multi-user deployments where users need personal settings and token management.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Multi-User BasicAuth
|
||||||
|
|
||||||
|
**Use Case:** Internal deployment where users provide their own credentials via HTTP headers.
|
||||||
|
|
||||||
|
#### MCP Client → MCP Server → Nextcloud
|
||||||
|
|
||||||
|
```
|
||||||
|
MCP Client MCP Server Nextcloud
|
||||||
|
│ │ │
|
||||||
|
│── MCP Request ─────────────▶│ │
|
||||||
|
│ Authorization: Basic │ │
|
||||||
|
│ (user credentials) │ │
|
||||||
|
│ │── BasicAuthMiddleware ────▶│
|
||||||
|
│ │ Extracts credentials │
|
||||||
|
│ │ │
|
||||||
|
│ │── HTTP + BasicAuth ───────▶│
|
||||||
|
│ │ (pass-through) │
|
||||||
|
│ │◀── API Response ───────────│
|
||||||
|
│◀── Tool Result ─────────────│ │
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key characteristics:**
|
||||||
|
- `BasicAuthMiddleware` extracts credentials from `Authorization: Basic` header
|
||||||
|
- Credentials passed through to Nextcloud (not stored)
|
||||||
|
- Client created per-request from extracted credentials
|
||||||
|
- Stateless - no credential storage between requests
|
||||||
|
|
||||||
|
**Implementation:** `context.py:187-248` - `_get_client_from_basic_auth()` extracts credentials from request state
|
||||||
|
|
||||||
|
#### Background Sync (Optional)
|
||||||
|
|
||||||
|
Requires `ENABLE_OFFLINE_ACCESS=true`. Users can store app passwords via Astrolabe for background operations.
|
||||||
|
|
||||||
|
```
|
||||||
|
Astrolabe MCP Server Nextcloud
|
||||||
|
│ │ │
|
||||||
|
│── Store App Password ──────▶│ │
|
||||||
|
│ (via management API) │ │
|
||||||
|
│ │── Store in SQLite ────────▶│
|
||||||
|
│ │ (encrypted) │
|
||||||
|
│◀── Confirmation ────────────│ │
|
||||||
|
│ │ │
|
||||||
|
│ [Background Job] │ │
|
||||||
|
│ │── Retrieve app password ──▶│
|
||||||
|
│ │ (from encrypted storage) │
|
||||||
|
│ │── HTTP + BasicAuth ───────▶│
|
||||||
|
│ │ (stored app password) │
|
||||||
|
│ │◀── API Response ───────────│
|
||||||
|
```
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
- `ENABLE_OFFLINE_ACCESS=true`
|
||||||
|
- `TOKEN_ENCRYPTION_KEY` for credential encryption
|
||||||
|
- `TOKEN_STORAGE_DB` for SQLite storage path
|
||||||
|
|
||||||
|
#### Astrolabe → MCP Server
|
||||||
|
|
||||||
|
```
|
||||||
|
Astrolabe MCP Server Nextcloud OIDC
|
||||||
|
│ │ │
|
||||||
|
│── OAuth Flow ──────────────▶│◀── Token from IdP ────────▶│
|
||||||
|
│ (user initiates) │ │
|
||||||
|
│ │ │
|
||||||
|
│── Bearer Token ────────────▶│ │
|
||||||
|
│ (management API calls) │ │
|
||||||
|
│ │── Validate via JWKS ──────▶│
|
||||||
|
│ │ (or introspection) │
|
||||||
|
│◀── API Response ────────────│ │
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key characteristics:**
|
||||||
|
- Astrolabe has its own OAuth client (`astrolabe_client_id` in Nextcloud config)
|
||||||
|
- Tokens are validated by MCP server using Nextcloud OIDC JWKS
|
||||||
|
- Authorization check: `token.sub == requested_resource_owner`
|
||||||
|
- Any valid Nextcloud OIDC token accepted (relaxed audience validation per ADR-018)
|
||||||
|
|
||||||
|
**Implementation:** `unified_verifier.py:120-183` - `verify_token_for_management_api()` validates without strict audience check
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. OAuth Single-Audience (Default)
|
||||||
|
|
||||||
|
**Use Case:** Multi-user deployment with OAuth authentication. Tokens work for both MCP and Nextcloud.
|
||||||
|
|
||||||
|
This is the default mode when `NEXTCLOUD_USERNAME`/`NEXTCLOUD_PASSWORD` are not set.
|
||||||
|
|
||||||
|
#### MCP Client → MCP Server → Nextcloud
|
||||||
|
|
||||||
|
```
|
||||||
|
MCP Client MCP Server Nextcloud
|
||||||
|
│ │ │
|
||||||
|
│── Bearer Token ────────────▶│ │
|
||||||
|
│ aud: ["mcp-server", │ │
|
||||||
|
│ "nextcloud"] │ │
|
||||||
|
│ │── Validate MCP audience ──▶│
|
||||||
|
│ │ (UnifiedTokenVerifier) │
|
||||||
|
│ │ │
|
||||||
|
│ │── HTTP + Same Token ──────▶│
|
||||||
|
│ │ Authorization: Bearer │
|
||||||
|
│ │ (multi-audience token) │
|
||||||
|
│ │ │
|
||||||
|
│ │ NC validates its own aud │
|
||||||
|
│ │◀── API Response ───────────│
|
||||||
|
│◀── Tool Result ─────────────│ │
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key characteristics:**
|
||||||
|
- Token contains both audiences: `aud: ["mcp-server", "nextcloud"]`
|
||||||
|
- MCP server validates only MCP audience (per RFC 7519)
|
||||||
|
- Nextcloud independently validates its own audience
|
||||||
|
- No token exchange needed - same token used throughout
|
||||||
|
- Stateless operation for interactive requests
|
||||||
|
|
||||||
|
**Token validation flow:**
|
||||||
|
1. `UnifiedTokenVerifier.verify_token()` validates MCP audience
|
||||||
|
2. Token passed directly to Nextcloud via `get_client_from_context()`
|
||||||
|
3. Nextcloud validates its own audience when receiving API calls
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
- `unified_verifier.py:185-252` - `_verify_mcp_audience()` validates MCP audience only
|
||||||
|
- `context.py:96-99` - Uses token directly in multi-audience mode
|
||||||
|
|
||||||
|
#### Background Sync
|
||||||
|
|
||||||
|
Requires `ENABLE_OFFLINE_ACCESS=true`. Uses stored refresh tokens to obtain access tokens for background operations.
|
||||||
|
|
||||||
|
```
|
||||||
|
MCP Server Nextcloud OIDC
|
||||||
|
│ │
|
||||||
|
[Background Job starts] │ │
|
||||||
|
│── Get refresh token ──────▶│
|
||||||
|
│ (from encrypted storage) │
|
||||||
|
│ │
|
||||||
|
│── Token refresh request ──▶│
|
||||||
|
│ grant_type=refresh_token │
|
||||||
|
│ scope=openid profile ... │
|
||||||
|
│◀── New access + refresh ───│
|
||||||
|
│ (rotation) │
|
||||||
|
│ │
|
||||||
|
│── Store rotated refresh ──▶│
|
||||||
|
│ (encrypted) │
|
||||||
|
│ │
|
||||||
|
│── HTTP + Access Token ────▶│
|
||||||
|
│ Authorization: Bearer │
|
||||||
|
│◀── API Response ───────────│
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key characteristics:**
|
||||||
|
- Refresh tokens stored encrypted in SQLite (`TOKEN_STORAGE_DB`)
|
||||||
|
- Nextcloud OIDC rotates refresh tokens on every use (one-time use)
|
||||||
|
- `TokenBrokerService` handles token lifecycle
|
||||||
|
- Per-user locking prevents race conditions during concurrent refresh
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
- `token_broker.py:269-362` - `get_background_token()` handles refresh with locking
|
||||||
|
- `token_broker.py:428-509` - `_refresh_access_token_with_scopes()` exchanges refresh token
|
||||||
|
|
||||||
|
#### Astrolabe → MCP Server
|
||||||
|
|
||||||
|
Same as Multi-User BasicAuth. See [Astrolabe → MCP Server](#astrolabe--mcp-server) above.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. OAuth Token Exchange (RFC 8693)
|
||||||
|
|
||||||
|
**Use Case:** Multi-user deployment where MCP tokens are separate from Nextcloud tokens. Provides stronger security boundaries.
|
||||||
|
|
||||||
|
Enabled by `ENABLE_TOKEN_EXCHANGE=true`.
|
||||||
|
|
||||||
|
#### MCP Client → MCP Server → Nextcloud
|
||||||
|
|
||||||
|
```
|
||||||
|
MCP Client MCP Server Nextcloud OIDC
|
||||||
|
│ │ │
|
||||||
|
│── Bearer Token ────────────▶│ │
|
||||||
|
│ aud: "mcp-server" │ │
|
||||||
|
│ (MCP audience only) │ │
|
||||||
|
│ │── Validate MCP audience ──▶│
|
||||||
|
│ │ │
|
||||||
|
│ │── RFC 8693 Exchange ──────▶│
|
||||||
|
│ │ grant_type= │
|
||||||
|
│ │ urn:ietf:params:oauth: │
|
||||||
|
│ │ grant-type:token-exchange
|
||||||
|
│ │ subject_token=<mcp-token>│
|
||||||
|
│ │ requested_audience= │
|
||||||
|
│ │ "nextcloud" │
|
||||||
|
│ │◀── Delegated Token ────────│
|
||||||
|
│ │ aud: "nextcloud" │
|
||||||
|
│ │ │
|
||||||
|
│ │── HTTP + Delegated Token ─▶│
|
||||||
|
│ │ Authorization: Bearer │
|
||||||
|
│ │◀── API Response ───────────│
|
||||||
|
│◀── Tool Result ─────────────│ │
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key characteristics:**
|
||||||
|
- Strict audience separation: MCP token has `aud: "mcp-server"` only
|
||||||
|
- Server exchanges for Nextcloud-audience token on each request
|
||||||
|
- Ephemeral delegated tokens (not cached by default)
|
||||||
|
- Strongest security boundary between MCP and Nextcloud access
|
||||||
|
|
||||||
|
**Token exchange details:**
|
||||||
|
- Uses RFC 8693 "urn:ietf:params:oauth:grant-type:token-exchange"
|
||||||
|
- Subject token: MCP access token
|
||||||
|
- Requested audience: Nextcloud resource URI
|
||||||
|
- Result: Short-lived token scoped for Nextcloud
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
- `token_broker.py:220-267` - `get_session_token()` performs on-demand exchange
|
||||||
|
- `token_exchange.py` - `exchange_token_for_delegation()` implements RFC 8693
|
||||||
|
- `context.py:88-94` - Routes to session client in exchange mode
|
||||||
|
|
||||||
|
#### Background Sync
|
||||||
|
|
||||||
|
Same as OAuth Single-Audience. Uses stored refresh tokens from Flow 2 provisioning.
|
||||||
|
|
||||||
|
```
|
||||||
|
MCP Server Nextcloud OIDC
|
||||||
|
│ │
|
||||||
|
[User provisions access] │ │
|
||||||
|
│── Flow 2 OAuth ───────────▶│
|
||||||
|
│ client_id="mcp-server" │
|
||||||
|
│ scope=offline_access ... │
|
||||||
|
│◀── Refresh Token ──────────│
|
||||||
|
│ (stored encrypted) │
|
||||||
|
│ │
|
||||||
|
[Background Job runs later] │ │
|
||||||
|
│── Refresh for background ─▶│
|
||||||
|
│ (same as single-audience)│
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key difference from interactive:**
|
||||||
|
- Interactive: On-demand token exchange per request
|
||||||
|
- Background: Uses pre-provisioned refresh tokens (Flow 2)
|
||||||
|
|
||||||
|
#### Astrolabe → MCP Server
|
||||||
|
|
||||||
|
Same as Multi-User BasicAuth. See [Astrolabe → MCP Server](#astrolabe--mcp-server) above.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Smithery Stateless
|
||||||
|
|
||||||
|
**Use Case:** Multi-tenant SaaS deployment via Smithery platform. Fully stateless.
|
||||||
|
|
||||||
|
Enabled by `SMITHERY_DEPLOYMENT=true`.
|
||||||
|
|
||||||
|
#### MCP Client → MCP Server → Nextcloud
|
||||||
|
|
||||||
|
```
|
||||||
|
MCP Client MCP Server Nextcloud
|
||||||
|
│ │ │
|
||||||
|
│── SSE Connect ─────────────▶│ │
|
||||||
|
│ ?nextcloud_url=... │ │
|
||||||
|
│ &username=... │ │
|
||||||
|
│ &app_password=... │ │
|
||||||
|
│ │── SmitheryConfigMiddleware │
|
||||||
|
│ │ Extract URL params │
|
||||||
|
│ │ │
|
||||||
|
│── MCP Request ─────────────▶│ │
|
||||||
|
│ (no Authorization header) │ │
|
||||||
|
│ │── Create per-request ─────▶│
|
||||||
|
│ │ NextcloudClient │
|
||||||
|
│ │ │
|
||||||
|
│ │── HTTP + BasicAuth ───────▶│
|
||||||
|
│ │ (from session params) │
|
||||||
|
│ │◀── API Response ───────────│
|
||||||
|
│◀── Tool Result ─────────────│ │
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key characteristics:**
|
||||||
|
- Configuration passed via URL query parameters (Smithery `configSchema`)
|
||||||
|
- No persistent state - client created fresh per request
|
||||||
|
- No OAuth infrastructure
|
||||||
|
- No background sync support (stateless)
|
||||||
|
- No admin UI available
|
||||||
|
|
||||||
|
**Required session parameters:**
|
||||||
|
- `nextcloud_url`: Nextcloud instance URL
|
||||||
|
- `username`: Nextcloud username
|
||||||
|
- `app_password`: Nextcloud app password
|
||||||
|
|
||||||
|
**Implementation:** `context.py:108-184` - `_get_client_from_session_config()` creates client from session params
|
||||||
|
|
||||||
|
#### Background Sync
|
||||||
|
|
||||||
|
Not supported. Smithery mode is fully stateless with no credential storage.
|
||||||
|
|
||||||
|
#### Astrolabe Integration
|
||||||
|
|
||||||
|
Not applicable. Smithery deployments don't integrate with Astrolabe.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Astrolabe Background Token Refresh
|
||||||
|
|
||||||
|
The Astrolabe Nextcloud app includes a background job that proactively refreshes OAuth tokens before expiration.
|
||||||
|
|
||||||
|
```
|
||||||
|
Nextcloud Cron Astrolabe MCP Server IdP
|
||||||
|
│ │ │
|
||||||
|
│── Run RefreshUserTokens ───▶│ │
|
||||||
|
│ (every 15 minutes) │ │
|
||||||
|
│ │── Get all user tokens ────▶│
|
||||||
|
│ │ (from preferences) │
|
||||||
|
│ │ │
|
||||||
|
│ [For each user] │ │
|
||||||
|
│ │── Check expiry ───────────▶│
|
||||||
|
│ │ refresh if <50% lifetime │
|
||||||
|
│ │ │
|
||||||
|
│ │── Acquire user lock ──────▶│
|
||||||
|
│ │ (prevent race condition) │
|
||||||
|
│ │ │
|
||||||
|
│ │── Token refresh request ──▶│
|
||||||
|
│ │ grant_type=refresh_token │
|
||||||
|
│ │◀── New tokens ─────────────│
|
||||||
|
│ │ │
|
||||||
|
│ │── Store new tokens ───────▶│
|
||||||
|
│ │ (with issued_at) │
|
||||||
|
│◀── Job complete ────────────│ │
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key characteristics:**
|
||||||
|
- Runs every 15 minutes via Nextcloud cron
|
||||||
|
- Refreshes when <50% of token lifetime remains
|
||||||
|
- Uses locking to prevent race conditions with on-demand refresh
|
||||||
|
- Stores `issued_at` timestamp for accurate lifetime calculation
|
||||||
|
- Batch processing (100 users at a time) for memory efficiency
|
||||||
|
|
||||||
|
**Implementation:** `third_party/astrolabe/lib/BackgroundJob/RefreshUserTokens.php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration Quick Reference
|
||||||
|
|
||||||
|
### Single-User BasicAuth
|
||||||
|
```bash
|
||||||
|
NEXTCLOUD_HOST=http://localhost:8080
|
||||||
|
NEXTCLOUD_USERNAME=admin
|
||||||
|
NEXTCLOUD_PASSWORD=password
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multi-User BasicAuth
|
||||||
|
```bash
|
||||||
|
NEXTCLOUD_HOST=http://nextcloud.example.com
|
||||||
|
ENABLE_MULTI_USER_BASIC_AUTH=true
|
||||||
|
|
||||||
|
# Optional: For background sync
|
||||||
|
ENABLE_OFFLINE_ACCESS=true
|
||||||
|
TOKEN_ENCRYPTION_KEY=<32-byte-key>
|
||||||
|
TOKEN_STORAGE_DB=/data/tokens.db
|
||||||
|
```
|
||||||
|
|
||||||
|
### OAuth Single-Audience (Default)
|
||||||
|
```bash
|
||||||
|
NEXTCLOUD_HOST=http://nextcloud.example.com
|
||||||
|
# No username/password triggers OAuth mode
|
||||||
|
|
||||||
|
# Optional: Static client credentials (instead of DCR)
|
||||||
|
NEXTCLOUD_OIDC_CLIENT_ID=<client-id>
|
||||||
|
NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret>
|
||||||
|
|
||||||
|
# Optional: For background sync
|
||||||
|
ENABLE_OFFLINE_ACCESS=true
|
||||||
|
TOKEN_ENCRYPTION_KEY=<32-byte-key>
|
||||||
|
TOKEN_STORAGE_DB=/data/tokens.db
|
||||||
|
```
|
||||||
|
|
||||||
|
### OAuth Token Exchange
|
||||||
|
```bash
|
||||||
|
NEXTCLOUD_HOST=http://nextcloud.example.com
|
||||||
|
ENABLE_TOKEN_EXCHANGE=true
|
||||||
|
NEXTCLOUD_OIDC_CLIENT_ID=<client-id>
|
||||||
|
NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret>
|
||||||
|
|
||||||
|
# Optional: For background sync
|
||||||
|
ENABLE_OFFLINE_ACCESS=true
|
||||||
|
TOKEN_ENCRYPTION_KEY=<32-byte-key>
|
||||||
|
TOKEN_STORAGE_DB=/data/tokens.db
|
||||||
|
```
|
||||||
|
|
||||||
|
### Smithery Stateless
|
||||||
|
```bash
|
||||||
|
SMITHERY_DEPLOYMENT=true
|
||||||
|
# All other config comes from session URL parameters
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [Authentication](authentication.md) - Configuration details and setup guides
|
||||||
|
- [OAuth Architecture](oauth-architecture.md) - Deep OAuth protocol details
|
||||||
|
- [ADR-004: Progressive Consent](ADR-004-mcp-application-oauth.md) - Dual OAuth flow architecture
|
||||||
|
- [ADR-005: Token Audience Validation](ADR-005-token-audience-validation.md) - Audience validation strategy
|
||||||
|
- [ADR-018: Nextcloud PHP App](ADR-018-nextcloud-php-app-for-settings-ui.md) - Astrolabe integration
|
||||||
|
- [ADR-020: Deployment Modes](ADR-020-deployment-modes-and-configuration-validation.md) - Mode detection and validation
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "nextcloud-mcp-server"
|
name = "nextcloud-mcp-server"
|
||||||
version = "0.61.5"
|
version = "0.62.0"
|
||||||
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
|
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Chris Coutinho", email = "chris@coutinho.io"}
|
{name = "Chris Coutinho", email = "chris@coutinho.io"}
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[tool.commitizen]
|
[tool.commitizen]
|
||||||
name = "cz_conventional_commits"
|
name = "cz_conventional_commits"
|
||||||
version = "0.8.3"
|
version = "0.9.0"
|
||||||
tag_format = "astrolabe-v$version"
|
tag_format = "astrolabe-v$version"
|
||||||
version_scheme = "semver"
|
version_scheme = "semver"
|
||||||
update_changelog_on_bump = true
|
update_changelog_on_bump = true
|
||||||
|
|||||||
Vendored
+22
@@ -25,6 +25,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Requires external MCP server deployment
|
- Requires external MCP server deployment
|
||||||
- See documentation for setup: https://github.com/cbcoutinho/nextcloud-mcp-server
|
- See documentation for setup: https://github.com/cbcoutinho/nextcloud-mcp-server
|
||||||
|
|
||||||
|
## astrolabe-v0.9.0 (2026-01-26)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- **scripts**: add database query helpers for development
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **astrolabe**: resolve Psalm type errors in PDF preview code
|
||||||
|
- **astrolabe**: fix Psalm baseline and ESLint import order
|
||||||
|
- **astrolabe**: load pdfjs-dist externally to fix PDF viewer
|
||||||
|
- **astrolabe**: improve error messages for authorization issues
|
||||||
|
- **astrolabe**: rename OAuthController and fix app password check
|
||||||
|
- **tests**: improve Astrolabe integration test reliability
|
||||||
|
- **astrolabe**: update Plotly title attributes for v3 compatibility
|
||||||
|
- **deps**: update dependency plotly.js-dist-min to v3
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- **api**: split management.py into domain-focused modules
|
||||||
|
- **astrolabe**: replace client-side PDF.js with server-side PyMuPDF rendering
|
||||||
|
|
||||||
## astrolabe-v0.8.3 (2026-01-17)
|
## astrolabe-v0.8.3 (2026-01-17)
|
||||||
|
|
||||||
### Fix
|
### Fix
|
||||||
|
|||||||
+4
-1
@@ -29,7 +29,7 @@ Astrolabe connects to a semantic search service that understands the meaning of
|
|||||||
|
|
||||||
See [documentation](https://github.com/cbcoutinho/nextcloud-mcp-server) for configuration details.
|
See [documentation](https://github.com/cbcoutinho/nextcloud-mcp-server) for configuration details.
|
||||||
]]></description>
|
]]></description>
|
||||||
<version>0.8.3</version>
|
<version>0.9.0</version>
|
||||||
<licence>agpl</licence>
|
<licence>agpl</licence>
|
||||||
<author homepage="https://github.com/cbcoutinho">Chris Coutinho</author>
|
<author homepage="https://github.com/cbcoutinho">Chris Coutinho</author>
|
||||||
<namespace>Astrolabe</namespace>
|
<namespace>Astrolabe</namespace>
|
||||||
@@ -57,4 +57,7 @@ See [documentation](https://github.com/cbcoutinho/nextcloud-mcp-server) for conf
|
|||||||
<type>link</type>
|
<type>link</type>
|
||||||
</navigation>
|
</navigation>
|
||||||
</navigations>
|
</navigations>
|
||||||
|
<background-jobs>
|
||||||
|
<job>OCA\Astrolabe\BackgroundJob\RefreshUserTokens</job>
|
||||||
|
</background-jobs>
|
||||||
</info>
|
</info>
|
||||||
|
|||||||
Vendored
+1
@@ -39,6 +39,7 @@
|
|||||||
"php": "^8.1"
|
"php": "^8.1"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
|
"doctrine/dbal": "^3.8",
|
||||||
"nextcloud/ocp": "dev-stable30",
|
"nextcloud/ocp": "dev-stable30",
|
||||||
"phpunit/phpunit": "^10.0",
|
"phpunit/phpunit": "^10.0",
|
||||||
"roave/security-advisories": "dev-latest"
|
"roave/security-advisories": "dev-latest"
|
||||||
|
|||||||
+303
-1
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "94a9d7f7619235ef2a310deec2ce14f0",
|
"content-hash": "e6ea5a770c578a5d7694602bb2618cef",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "bamarni/composer-bin-plugin",
|
"name": "bamarni/composer-bin-plugin",
|
||||||
@@ -65,6 +65,259 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"packages-dev": [
|
"packages-dev": [
|
||||||
|
{
|
||||||
|
"name": "doctrine/dbal",
|
||||||
|
"version": "3.10.4",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/doctrine/dbal.git",
|
||||||
|
"reference": "63a46cb5aa6f60991186cc98c1d1b50c09311868"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/doctrine/dbal/zipball/63a46cb5aa6f60991186cc98c1d1b50c09311868",
|
||||||
|
"reference": "63a46cb5aa6f60991186cc98c1d1b50c09311868",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"composer-runtime-api": "^2",
|
||||||
|
"doctrine/deprecations": "^0.5.3|^1",
|
||||||
|
"doctrine/event-manager": "^1|^2",
|
||||||
|
"php": "^7.4 || ^8.0",
|
||||||
|
"psr/cache": "^1|^2|^3",
|
||||||
|
"psr/log": "^1|^2|^3"
|
||||||
|
},
|
||||||
|
"conflict": {
|
||||||
|
"doctrine/cache": "< 1.11"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"doctrine/cache": "^1.11|^2.0",
|
||||||
|
"doctrine/coding-standard": "14.0.0",
|
||||||
|
"fig/log-test": "^1",
|
||||||
|
"jetbrains/phpstorm-stubs": "2023.1",
|
||||||
|
"phpstan/phpstan": "2.1.30",
|
||||||
|
"phpstan/phpstan-strict-rules": "^2",
|
||||||
|
"phpunit/phpunit": "9.6.29",
|
||||||
|
"slevomat/coding-standard": "8.24.0",
|
||||||
|
"squizlabs/php_codesniffer": "4.0.0",
|
||||||
|
"symfony/cache": "^5.4|^6.0|^7.0|^8.0",
|
||||||
|
"symfony/console": "^4.4|^5.4|^6.0|^7.0|^8.0"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"symfony/console": "For helpful console commands such as SQL execution and import of files."
|
||||||
|
},
|
||||||
|
"bin": [
|
||||||
|
"bin/doctrine-dbal"
|
||||||
|
],
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Doctrine\\DBAL\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Guilherme Blanco",
|
||||||
|
"email": "guilhermeblanco@gmail.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Roman Borschel",
|
||||||
|
"email": "roman@code-factory.org"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Benjamin Eberlei",
|
||||||
|
"email": "kontakt@beberlei.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Jonathan Wage",
|
||||||
|
"email": "jonwage@gmail.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Powerful PHP database abstraction layer (DBAL) with many features for database schema introspection and management.",
|
||||||
|
"homepage": "https://www.doctrine-project.org/projects/dbal.html",
|
||||||
|
"keywords": [
|
||||||
|
"abstraction",
|
||||||
|
"database",
|
||||||
|
"db2",
|
||||||
|
"dbal",
|
||||||
|
"mariadb",
|
||||||
|
"mssql",
|
||||||
|
"mysql",
|
||||||
|
"oci8",
|
||||||
|
"oracle",
|
||||||
|
"pdo",
|
||||||
|
"pgsql",
|
||||||
|
"postgresql",
|
||||||
|
"queryobject",
|
||||||
|
"sasql",
|
||||||
|
"sql",
|
||||||
|
"sqlite",
|
||||||
|
"sqlserver",
|
||||||
|
"sqlsrv"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/doctrine/dbal/issues",
|
||||||
|
"source": "https://github.com/doctrine/dbal/tree/3.10.4"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://www.doctrine-project.org/sponsorship.html",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://www.patreon.com/phpdoctrine",
|
||||||
|
"type": "patreon"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdbal",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2025-11-29T10:46:08+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "doctrine/deprecations",
|
||||||
|
"version": "1.1.5",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/doctrine/deprecations.git",
|
||||||
|
"reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/doctrine/deprecations/zipball/459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38",
|
||||||
|
"reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^7.1 || ^8.0"
|
||||||
|
},
|
||||||
|
"conflict": {
|
||||||
|
"phpunit/phpunit": "<=7.5 || >=13"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"doctrine/coding-standard": "^9 || ^12 || ^13",
|
||||||
|
"phpstan/phpstan": "1.4.10 || 2.1.11",
|
||||||
|
"phpstan/phpstan-phpunit": "^1.0 || ^2",
|
||||||
|
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12",
|
||||||
|
"psr/log": "^1 || ^2 || ^3"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"psr/log": "Allows logging deprecations via PSR-3 logger implementation"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Doctrine\\Deprecations\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.",
|
||||||
|
"homepage": "https://www.doctrine-project.org/",
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/doctrine/deprecations/issues",
|
||||||
|
"source": "https://github.com/doctrine/deprecations/tree/1.1.5"
|
||||||
|
},
|
||||||
|
"time": "2025-04-07T20:06:18+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "doctrine/event-manager",
|
||||||
|
"version": "2.1.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/doctrine/event-manager.git",
|
||||||
|
"reference": "c07799fcf5ad362050960a0fd068dded40b1e312"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/doctrine/event-manager/zipball/c07799fcf5ad362050960a0fd068dded40b1e312",
|
||||||
|
"reference": "c07799fcf5ad362050960a0fd068dded40b1e312",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^8.1"
|
||||||
|
},
|
||||||
|
"conflict": {
|
||||||
|
"doctrine/common": "<2.9"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"doctrine/coding-standard": "^14",
|
||||||
|
"phpdocumentor/guides-cli": "^1.4",
|
||||||
|
"phpstan/phpstan": "^2.1.32",
|
||||||
|
"phpunit/phpunit": "^10.5.58"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Doctrine\\Common\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Guilherme Blanco",
|
||||||
|
"email": "guilhermeblanco@gmail.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Roman Borschel",
|
||||||
|
"email": "roman@code-factory.org"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Benjamin Eberlei",
|
||||||
|
"email": "kontakt@beberlei.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Jonathan Wage",
|
||||||
|
"email": "jonwage@gmail.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Johannes Schmitt",
|
||||||
|
"email": "schmittjoh@gmail.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Marco Pivetta",
|
||||||
|
"email": "ocramius@gmail.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "The Doctrine Event Manager is a simple PHP event system that was built to be used with the various Doctrine projects.",
|
||||||
|
"homepage": "https://www.doctrine-project.org/projects/event-manager.html",
|
||||||
|
"keywords": [
|
||||||
|
"event",
|
||||||
|
"event dispatcher",
|
||||||
|
"event manager",
|
||||||
|
"event system",
|
||||||
|
"events"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/doctrine/event-manager/issues",
|
||||||
|
"source": "https://github.com/doctrine/event-manager/tree/2.1.0"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://www.doctrine-project.org/sponsorship.html",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://www.patreon.com/phpdoctrine",
|
||||||
|
"type": "patreon"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/doctrine%2Fevent-manager",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-01-17T22:40:21+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "myclabs/deep-copy",
|
"name": "myclabs/deep-copy",
|
||||||
"version": "1.13.4",
|
"version": "1.13.4",
|
||||||
@@ -775,6 +1028,55 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-12-06T07:50:42+00:00"
|
"time": "2025-12-06T07:50:42+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "psr/cache",
|
||||||
|
"version": "3.0.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/php-fig/cache.git",
|
||||||
|
"reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf",
|
||||||
|
"reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.0.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "1.0.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Psr\\Cache\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "PHP-FIG",
|
||||||
|
"homepage": "https://www.php-fig.org/"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Common interface for caching libraries",
|
||||||
|
"keywords": [
|
||||||
|
"cache",
|
||||||
|
"psr",
|
||||||
|
"psr-6"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/php-fig/cache/tree/3.0.0"
|
||||||
|
},
|
||||||
|
"time": "2021-02-03T23:26:27+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "psr/clock",
|
"name": "psr/clock",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace OCA\Astrolabe\BackgroundJob;
|
||||||
|
|
||||||
|
use OCA\Astrolabe\Service\IdpTokenRefresher;
|
||||||
|
use OCA\Astrolabe\Service\McpTokenStorage;
|
||||||
|
use OCP\AppFramework\Utility\ITimeFactory;
|
||||||
|
use OCP\BackgroundJob\IJob;
|
||||||
|
use OCP\BackgroundJob\TimedJob;
|
||||||
|
use OCP\Lock\LockedException;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Background job to proactively refresh OAuth tokens before expiration.
|
||||||
|
*
|
||||||
|
* Runs every 15 minutes and refreshes tokens based on their actual expiration
|
||||||
|
* time. Works with any IdP (Nextcloud OIDC, Keycloak, etc.) since it uses
|
||||||
|
* the real token expiration rather than IdP configuration.
|
||||||
|
*
|
||||||
|
* Refresh strategy: Refresh when less than 50% of token lifetime remains,
|
||||||
|
* ensuring tokens are refreshed well before expiration regardless of the
|
||||||
|
* IdP's configured token lifetime.
|
||||||
|
*
|
||||||
|
* @psalm-suppress UnusedClass - Background jobs are loaded dynamically by Nextcloud
|
||||||
|
*/
|
||||||
|
class RefreshUserTokens extends TimedJob {
|
||||||
|
/** Job runs every 15 minutes */
|
||||||
|
private const JOB_INTERVAL_SECONDS = 900;
|
||||||
|
|
||||||
|
/** Refresh when this percentage of token lifetime remains */
|
||||||
|
private const REFRESH_AT_REMAINING_PERCENT = 0.5;
|
||||||
|
|
||||||
|
/** Minimum threshold to avoid constant refresh (5 minutes) */
|
||||||
|
private const MIN_THRESHOLD_SECONDS = 300;
|
||||||
|
|
||||||
|
/** Default assumed token lifetime if we can't determine it (1 hour) */
|
||||||
|
private const DEFAULT_TOKEN_LIFETIME_SECONDS = 3600;
|
||||||
|
|
||||||
|
/** Batch size for processing users (prevents memory issues on large installations) */
|
||||||
|
private const BATCH_SIZE = 100;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
ITimeFactory $time,
|
||||||
|
private McpTokenStorage $tokenStorage,
|
||||||
|
private IdpTokenRefresher $tokenRefresher,
|
||||||
|
private LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
parent::__construct($time);
|
||||||
|
$this->setInterval(self::JOB_INTERVAL_SECONDS);
|
||||||
|
$this->setTimeSensitivity(IJob::TIME_INSENSITIVE);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function run(mixed $argument): void {
|
||||||
|
$this->logger->info('RefreshUserTokens: Starting background token refresh');
|
||||||
|
|
||||||
|
$refreshed = 0;
|
||||||
|
$failed = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
$offset = 0;
|
||||||
|
$totalUsers = 0;
|
||||||
|
|
||||||
|
// Process users in batches to prevent memory issues on large installations
|
||||||
|
do {
|
||||||
|
$userIds = $this->tokenStorage->getAllUsersWithTokens(self::BATCH_SIZE, $offset);
|
||||||
|
$batchCount = count($userIds);
|
||||||
|
$totalUsers += $batchCount;
|
||||||
|
|
||||||
|
foreach ($userIds as $userId) {
|
||||||
|
$result = $this->refreshUserTokenIfNeeded($userId);
|
||||||
|
match ($result) {
|
||||||
|
'refreshed' => $refreshed++,
|
||||||
|
'failed' => $failed++,
|
||||||
|
'skipped' => $skipped++,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
$offset += self::BATCH_SIZE;
|
||||||
|
} while ($batchCount === self::BATCH_SIZE);
|
||||||
|
|
||||||
|
$this->logger->info("RefreshUserTokens: Complete - total=$totalUsers, refreshed=$refreshed, failed=$failed, skipped=$skipped");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh a user's token if it's nearing expiration.
|
||||||
|
*
|
||||||
|
* Calculates the refresh threshold based on the token's actual lifetime,
|
||||||
|
* refreshing when less than 50% of the lifetime remains.
|
||||||
|
*
|
||||||
|
* Uses locking to prevent race conditions with on-demand refresh in
|
||||||
|
* getAccessToken(). If lock cannot be acquired, skips this user since
|
||||||
|
* on-demand refresh is already handling it.
|
||||||
|
*
|
||||||
|
* @return string 'refreshed', 'failed', or 'skipped'
|
||||||
|
*/
|
||||||
|
private function refreshUserTokenIfNeeded(string $userId): string {
|
||||||
|
$token = $this->tokenStorage->getUserToken($userId);
|
||||||
|
|
||||||
|
if ($token === null) {
|
||||||
|
return 'skipped';
|
||||||
|
}
|
||||||
|
|
||||||
|
$expiresAt = (int)($token['expires_at'] ?? 0);
|
||||||
|
$issuedAt = isset($token['issued_at']) ? (int)$token['issued_at'] : null;
|
||||||
|
$timeRemaining = $expiresAt - time();
|
||||||
|
|
||||||
|
// Calculate token lifetime from stored data or use default
|
||||||
|
if ($issuedAt !== null) {
|
||||||
|
$tokenLifetime = $expiresAt - $issuedAt;
|
||||||
|
} else {
|
||||||
|
// Fallback: use default lifetime assumption
|
||||||
|
$tokenLifetime = self::DEFAULT_TOKEN_LIFETIME_SECONDS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate threshold: refresh when 50% of lifetime remains
|
||||||
|
$threshold = max(
|
||||||
|
(int)($tokenLifetime * self::REFRESH_AT_REMAINING_PERCENT),
|
||||||
|
self::MIN_THRESHOLD_SECONDS
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($timeRemaining > $threshold) {
|
||||||
|
// Token still has plenty of time, skip
|
||||||
|
return 'skipped';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token is expiring soon, attempt refresh with lock
|
||||||
|
try {
|
||||||
|
return $this->tokenStorage->withTokenLock($userId, function () use ($userId) {
|
||||||
|
// Re-check token after acquiring lock (double-check pattern)
|
||||||
|
// Another process may have refreshed while we waited for lock
|
||||||
|
$currentToken = $this->tokenStorage->getUserToken($userId);
|
||||||
|
|
||||||
|
if ($currentToken === null) {
|
||||||
|
return 'skipped';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recalculate threshold with current token data
|
||||||
|
$currentExpiresAt = (int)($currentToken['expires_at'] ?? 0);
|
||||||
|
$currentIssuedAt = isset($currentToken['issued_at']) ? (int)$currentToken['issued_at'] : null;
|
||||||
|
$currentTimeRemaining = $currentExpiresAt - time();
|
||||||
|
|
||||||
|
if ($currentIssuedAt !== null) {
|
||||||
|
$currentTokenLifetime = $currentExpiresAt - $currentIssuedAt;
|
||||||
|
} else {
|
||||||
|
$currentTokenLifetime = self::DEFAULT_TOKEN_LIFETIME_SECONDS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentThreshold = max(
|
||||||
|
(int)($currentTokenLifetime * self::REFRESH_AT_REMAINING_PERCENT),
|
||||||
|
self::MIN_THRESHOLD_SECONDS
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($currentTimeRemaining > $currentThreshold) {
|
||||||
|
// Token was refreshed by another process while we waited
|
||||||
|
$this->logger->debug("RefreshUserTokens: Token already refreshed for user $userId while waiting for lock");
|
||||||
|
return 'skipped';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Still needs refresh, proceed
|
||||||
|
if (!isset($currentToken['refresh_token'])) {
|
||||||
|
$this->logger->warning("RefreshUserTokens: User $userId has no refresh token");
|
||||||
|
return 'failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logger->debug("RefreshUserTokens: Refreshing token for user $userId (remaining={$currentTimeRemaining}s, threshold={$currentThreshold}s)");
|
||||||
|
|
||||||
|
/** @var string $refreshToken */
|
||||||
|
$refreshToken = $currentToken['refresh_token'];
|
||||||
|
$newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken);
|
||||||
|
|
||||||
|
if ($newTokenData === null) {
|
||||||
|
$this->logger->warning("RefreshUserTokens: Refresh returned null for user $userId");
|
||||||
|
// Don't delete token here - let on-demand refresh handle cleanup
|
||||||
|
return 'failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate new expiration and store issued_at for future calculations
|
||||||
|
$expiresIn = (int)($newTokenData['expires_in'] ?? self::DEFAULT_TOKEN_LIFETIME_SECONDS);
|
||||||
|
$now = time();
|
||||||
|
|
||||||
|
/** @var string $accessToken */
|
||||||
|
$accessToken = $newTokenData['access_token'];
|
||||||
|
/** @var string $newRefreshToken */
|
||||||
|
$newRefreshToken = $newTokenData['refresh_token'] ?? $refreshToken;
|
||||||
|
|
||||||
|
$this->tokenStorage->storeUserToken(
|
||||||
|
$userId,
|
||||||
|
$accessToken,
|
||||||
|
$newRefreshToken,
|
||||||
|
$now + $expiresIn,
|
||||||
|
$now // issued_at
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->logger->debug("RefreshUserTokens: Successfully refreshed token for user $userId");
|
||||||
|
return 'refreshed';
|
||||||
|
});
|
||||||
|
} catch (LockedException $e) {
|
||||||
|
// Lock held by on-demand refresh - expected, not an error
|
||||||
|
$this->logger->debug("RefreshUserTokens: Lock held for user $userId, skipping");
|
||||||
|
return 'skipped';
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->logger->error("RefreshUserTokens: Failed to refresh for user $userId: " . $e->getMessage());
|
||||||
|
return 'failed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
-3
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace OCA\Astrolabe\Search;
|
namespace OCA\Astrolabe\Search;
|
||||||
|
|
||||||
use OCA\Astrolabe\AppInfo\Application;
|
use OCA\Astrolabe\AppInfo\Application;
|
||||||
|
use OCA\Astrolabe\Service\IdpTokenRefresher;
|
||||||
use OCA\Astrolabe\Service\McpServerClient;
|
use OCA\Astrolabe\Service\McpServerClient;
|
||||||
use OCA\Astrolabe\Service\McpTokenStorage;
|
use OCA\Astrolabe\Service\McpTokenStorage;
|
||||||
use OCA\Astrolabe\Settings\Admin as AdminSettings;
|
use OCA\Astrolabe\Settings\Admin as AdminSettings;
|
||||||
@@ -35,6 +36,7 @@ class SemanticSearchProvider implements IProvider {
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private McpServerClient $client,
|
private McpServerClient $client,
|
||||||
private McpTokenStorage $tokenStorage,
|
private McpTokenStorage $tokenStorage,
|
||||||
|
private IdpTokenRefresher $tokenRefresher,
|
||||||
private IConfig $config,
|
private IConfig $config,
|
||||||
private IL10N $l10n,
|
private IL10N $l10n,
|
||||||
private IURLGenerator $urlGenerator,
|
private IURLGenerator $urlGenerator,
|
||||||
@@ -85,12 +87,30 @@ class SemanticSearchProvider implements IProvider {
|
|||||||
return SearchResult::complete($this->getName(), []);
|
return SearchResult::complete($this->getName(), []);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get OAuth token for user
|
$userId = $user->getUID();
|
||||||
$accessToken = $this->tokenStorage->getAccessToken($user->getUID());
|
|
||||||
|
// Create refresh callback matching ApiController pattern
|
||||||
|
/** @return array{access_token: string, refresh_token: string, expires_in: int}|null */
|
||||||
|
$refreshCallback = function (string $refreshToken): ?array {
|
||||||
|
$newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken);
|
||||||
|
|
||||||
|
if ($newTokenData === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'access_token' => $newTokenData['access_token'],
|
||||||
|
'refresh_token' => $newTokenData['refresh_token'] ?? $refreshToken,
|
||||||
|
'expires_in' => $newTokenData['expires_in'] ?? 3600,
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get OAuth token for user with automatic refresh
|
||||||
|
$accessToken = $this->tokenStorage->getAccessToken($userId, $refreshCallback);
|
||||||
if ($accessToken === null) {
|
if ($accessToken === null) {
|
||||||
// User hasn't authorized the app yet - return empty results
|
// User hasn't authorized the app yet - return empty results
|
||||||
$this->logger->debug('No OAuth token for user in semantic search', [
|
$this->logger->debug('No OAuth token for user in semantic search', [
|
||||||
'user_id' => $user->getUID(),
|
'user_id' => $userId,
|
||||||
]);
|
]);
|
||||||
return SearchResult::complete($this->getName(), []);
|
return SearchResult::complete($this->getName(), []);
|
||||||
}
|
}
|
||||||
|
|||||||
+164
-33
@@ -5,6 +5,9 @@ declare(strict_types=1);
|
|||||||
namespace OCA\Astrolabe\Service;
|
namespace OCA\Astrolabe\Service;
|
||||||
|
|
||||||
use OCP\IConfig;
|
use OCP\IConfig;
|
||||||
|
use OCP\IDBConnection;
|
||||||
|
use OCP\Lock\ILockingProvider;
|
||||||
|
use OCP\Lock\LockedException;
|
||||||
use OCP\Security\ICrypto;
|
use OCP\Security\ICrypto;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
@@ -20,16 +23,22 @@ class McpTokenStorage {
|
|||||||
|
|
||||||
private $config;
|
private $config;
|
||||||
private $crypto;
|
private $crypto;
|
||||||
|
private $db;
|
||||||
private $logger;
|
private $logger;
|
||||||
|
private ILockingProvider $lockingProvider;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
IConfig $config,
|
IConfig $config,
|
||||||
ICrypto $crypto,
|
ICrypto $crypto,
|
||||||
|
IDBConnection $db,
|
||||||
LoggerInterface $logger,
|
LoggerInterface $logger,
|
||||||
|
ILockingProvider $lockingProvider,
|
||||||
) {
|
) {
|
||||||
$this->config = $config;
|
$this->config = $config;
|
||||||
$this->crypto = $crypto;
|
$this->crypto = $crypto;
|
||||||
|
$this->db = $db;
|
||||||
$this->logger = $logger;
|
$this->logger = $logger;
|
||||||
|
$this->lockingProvider = $lockingProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -41,18 +50,21 @@ class McpTokenStorage {
|
|||||||
* @param string $accessToken OAuth access token
|
* @param string $accessToken OAuth access token
|
||||||
* @param string $refreshToken OAuth refresh token
|
* @param string $refreshToken OAuth refresh token
|
||||||
* @param int $expiresAt Unix timestamp when token expires
|
* @param int $expiresAt Unix timestamp when token expires
|
||||||
|
* @param int|null $issuedAt Unix timestamp when token was issued (for lifetime calculation)
|
||||||
*/
|
*/
|
||||||
public function storeUserToken(
|
public function storeUserToken(
|
||||||
string $userId,
|
string $userId,
|
||||||
string $accessToken,
|
string $accessToken,
|
||||||
string $refreshToken,
|
string $refreshToken,
|
||||||
int $expiresAt,
|
int $expiresAt,
|
||||||
|
?int $issuedAt = null,
|
||||||
): void {
|
): void {
|
||||||
try {
|
try {
|
||||||
$tokenData = [
|
$tokenData = [
|
||||||
'access_token' => $accessToken,
|
'access_token' => $accessToken,
|
||||||
'refresh_token' => $refreshToken,
|
'refresh_token' => $refreshToken,
|
||||||
'expires_at' => $expiresAt,
|
'expires_at' => $expiresAt,
|
||||||
|
'issued_at' => $issuedAt ?? time(),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Encrypt token data before storage
|
// Encrypt token data before storage
|
||||||
@@ -129,6 +141,42 @@ class McpTokenStorage {
|
|||||||
return time() >= ($token['expires_at'] - self::TOKEN_EXPIRY_BUFFER_SECONDS);
|
return time() >= ($token['expires_at'] - self::TOKEN_EXPIRY_BUFFER_SECONDS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the lock path for a user's token refresh operation.
|
||||||
|
*
|
||||||
|
* @param string $userId User ID
|
||||||
|
* @return string Lock path
|
||||||
|
*/
|
||||||
|
private function getTokenRefreshLockPath(string $userId): string {
|
||||||
|
return 'astrolabe/oauth/tokens/' . $userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute callback while holding exclusive lock on user's token.
|
||||||
|
*
|
||||||
|
* Prevents race conditions between background job and on-demand token refresh.
|
||||||
|
*
|
||||||
|
* Note: Lock TTL is configured at the Nextcloud server level (default: 3600s).
|
||||||
|
* If a process crashes while holding the lock, it will auto-expire after the TTL.
|
||||||
|
* The ILockingProvider interface does not support per-call timeouts.
|
||||||
|
*
|
||||||
|
* @template T
|
||||||
|
* @param string $userId User ID
|
||||||
|
* @param callable(): T $callback
|
||||||
|
* @return T
|
||||||
|
* @throws LockedException If lock cannot be acquired
|
||||||
|
*/
|
||||||
|
public function withTokenLock(string $userId, callable $callback): mixed {
|
||||||
|
$lockPath = $this->getTokenRefreshLockPath($userId);
|
||||||
|
|
||||||
|
$this->lockingProvider->acquireLock($lockPath, ILockingProvider::LOCK_EXCLUSIVE);
|
||||||
|
try {
|
||||||
|
return $callback();
|
||||||
|
} finally {
|
||||||
|
$this->lockingProvider->releaseLock($lockPath, ILockingProvider::LOCK_EXCLUSIVE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete stored tokens for a user.
|
* Delete stored tokens for a user.
|
||||||
*
|
*
|
||||||
@@ -153,65 +201,148 @@ class McpTokenStorage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user IDs that have OAuth tokens stored.
|
||||||
|
*
|
||||||
|
* Queries oc_preferences directly since IConfig doesn't support
|
||||||
|
* listing all users with a specific key set.
|
||||||
|
*
|
||||||
|
* @param int $limit Maximum users to return (0 = no limit, for backward compatibility)
|
||||||
|
* @param int $offset Starting offset for pagination
|
||||||
|
* @return list<string> Array of user IDs
|
||||||
|
*/
|
||||||
|
public function getAllUsersWithTokens(int $limit = 0, int $offset = 0): array {
|
||||||
|
$qb = $this->db->getQueryBuilder();
|
||||||
|
$qb->select('userid')
|
||||||
|
->from('preferences')
|
||||||
|
->where($qb->expr()->eq('appid', $qb->createNamedParameter('astrolabe')))
|
||||||
|
->andWhere($qb->expr()->eq('configkey', $qb->createNamedParameter('oauth_tokens')));
|
||||||
|
|
||||||
|
if ($limit > 0) {
|
||||||
|
$qb->setMaxResults($limit);
|
||||||
|
}
|
||||||
|
if ($offset > 0) {
|
||||||
|
$qb->setFirstResult($offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $qb->executeQuery();
|
||||||
|
/** @var list<string> $userIds */
|
||||||
|
$userIds = [];
|
||||||
|
/** @psalm-suppress MixedAssignment - IResult::fetch() returns mixed */
|
||||||
|
while (($row = $result->fetch()) !== false) {
|
||||||
|
if (is_array($row) && isset($row['userid']) && is_string($row['userid'])) {
|
||||||
|
$userIds[] = $row['userid'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$result->closeCursor();
|
||||||
|
|
||||||
|
return $userIds;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the access token for a user, handling expiration and refresh.
|
* Get the access token for a user, handling expiration and refresh.
|
||||||
*
|
*
|
||||||
* This is a convenience method that combines token retrieval,
|
* This is a convenience method that combines token retrieval,
|
||||||
* expiration checking, and automatic refresh if needed.
|
* expiration checking, and automatic refresh if needed.
|
||||||
*
|
*
|
||||||
|
* Uses double-check locking pattern to prevent race conditions between
|
||||||
|
* background job and on-demand refresh while minimizing lock contention.
|
||||||
|
*
|
||||||
* @param string $userId User ID
|
* @param string $userId User ID
|
||||||
* @param callable|null $refreshCallback Callback to refresh token if expired
|
* @param callable|null $refreshCallback Callback to refresh token if expired
|
||||||
* Should accept (refreshToken) and return new token data
|
* Should accept (refreshToken) and return new token data
|
||||||
* @return string|null Access token, or null if not available
|
* @return string|null Access token, or null if not available
|
||||||
*/
|
*/
|
||||||
public function getAccessToken(string $userId, ?callable $refreshCallback = null): ?string {
|
public function getAccessToken(string $userId, ?callable $refreshCallback = null): ?string {
|
||||||
|
// Quick check without lock (optimization)
|
||||||
$token = $this->getUserToken($userId);
|
$token = $this->getUserToken($userId);
|
||||||
|
|
||||||
if (!$token) {
|
if (!$token) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if token is expired
|
// If not expired, return immediately without lock
|
||||||
if ($this->isExpired($token)) {
|
if (!$this->isExpired($token)) {
|
||||||
// Try to refresh if callback provided
|
return $token['access_token'];
|
||||||
if ($refreshCallback && isset($token['refresh_token'])) {
|
}
|
||||||
try {
|
|
||||||
$newTokenData = $refreshCallback($token['refresh_token']);
|
|
||||||
|
|
||||||
if ($newTokenData && isset($newTokenData['access_token'])) {
|
// Token expired - acquire lock for refresh
|
||||||
// Store refreshed token
|
try {
|
||||||
// Use new refresh token if provided (rotation), otherwise keep old one
|
/**
|
||||||
$this->storeUserToken(
|
* @return string|null
|
||||||
$userId,
|
* @psalm-suppress MixedInferredReturnType
|
||||||
$newTokenData['access_token'],
|
*/
|
||||||
$newTokenData['refresh_token'] ?? $token['refresh_token'],
|
return $this->withTokenLock($userId, function () use ($userId, $refreshCallback): ?string {
|
||||||
time() + ($newTokenData['expires_in'] ?? 3600)
|
// Re-check after acquiring lock (double-check pattern)
|
||||||
);
|
// Another process may have refreshed while we waited for the lock
|
||||||
|
$currentToken = $this->getUserToken($userId);
|
||||||
|
|
||||||
return $newTokenData['access_token'];
|
if ($currentToken === null) {
|
||||||
}
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
$this->logger->error("Failed to refresh token for user $userId", [
|
|
||||||
'error' => $e->getMessage()
|
|
||||||
]);
|
|
||||||
// Delete stale token to prevent repeated refresh attempts
|
|
||||||
$this->deleteUserToken($userId);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh callback returned null or invalid data - delete stale token
|
// Check if another process already refreshed the token
|
||||||
|
if (!$this->isExpired($currentToken)) {
|
||||||
|
$this->logger->debug("Token already refreshed for user $userId while waiting for lock");
|
||||||
|
/** @var string */
|
||||||
|
return $currentToken['access_token'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Still expired, perform refresh
|
||||||
|
if ($refreshCallback && isset($currentToken['refresh_token'])) {
|
||||||
|
try {
|
||||||
|
/** @var string $refreshToken */
|
||||||
|
$refreshToken = $currentToken['refresh_token'];
|
||||||
|
$newTokenData = $refreshCallback($refreshToken);
|
||||||
|
|
||||||
|
if ($newTokenData && isset($newTokenData['access_token'])) {
|
||||||
|
// Store refreshed token
|
||||||
|
// Use new refresh token if provided (rotation), otherwise keep old one
|
||||||
|
$now = time();
|
||||||
|
/** @var string $accessToken */
|
||||||
|
$accessToken = $newTokenData['access_token'];
|
||||||
|
/** @var string $newRefreshToken */
|
||||||
|
$newRefreshToken = $newTokenData['refresh_token'] ?? $refreshToken;
|
||||||
|
$expiresIn = (int)($newTokenData['expires_in'] ?? 3600);
|
||||||
|
|
||||||
|
$this->storeUserToken(
|
||||||
|
$userId,
|
||||||
|
$accessToken,
|
||||||
|
$newRefreshToken,
|
||||||
|
$now + $expiresIn,
|
||||||
|
$now // issued_at for accurate lifetime calculation
|
||||||
|
);
|
||||||
|
|
||||||
|
return $accessToken;
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->logger->error("Failed to refresh token for user $userId", [
|
||||||
|
'error' => $e->getMessage()
|
||||||
|
]);
|
||||||
|
// Delete stale token to prevent repeated refresh attempts
|
||||||
|
$this->deleteUserToken($userId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh callback returned null or invalid data - delete stale token
|
||||||
|
$this->deleteUserToken($userId);
|
||||||
|
$this->logger->info("Deleted stale token for user $userId after refresh failure");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token expired and no refresh callback available - delete stale token
|
||||||
$this->deleteUserToken($userId);
|
$this->deleteUserToken($userId);
|
||||||
$this->logger->info("Deleted stale token for user $userId after refresh failure");
|
$this->logger->info("Token expired for user $userId, no refresh available");
|
||||||
return null;
|
return null;
|
||||||
}
|
});
|
||||||
|
} catch (LockedException $e) {
|
||||||
// Token expired and no refresh callback available - delete stale token
|
// Could not acquire lock - another process is refreshing
|
||||||
$this->deleteUserToken($userId);
|
// Return stale token rather than failing - caller can retry if needed
|
||||||
$this->logger->info("Token expired for user $userId, no refresh available");
|
$this->logger->warning("Could not acquire token lock for user $userId, returning stale token");
|
||||||
return null;
|
/** @var string|null $staleToken */
|
||||||
|
$staleToken = $token['access_token'] ?? null;
|
||||||
|
return $staleToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $token['access_token'];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "astrolabe",
|
"name": "astrolabe",
|
||||||
"version": "0.8.3",
|
"version": "0.9.0",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^22.0.0",
|
"node": "^22.0.0",
|
||||||
|
|||||||
-7
@@ -388,11 +388,6 @@
|
|||||||
<InvalidReturnType>
|
<InvalidReturnType>
|
||||||
<code><![CDATA[array|null]]></code>
|
<code><![CDATA[array|null]]></code>
|
||||||
</InvalidReturnType>
|
</InvalidReturnType>
|
||||||
<MixedArgument>
|
|
||||||
<code><![CDATA[$newTokenData['access_token']]]></code>
|
|
||||||
<code><![CDATA[$newTokenData['refresh_token'] ?? $token['refresh_token']]]></code>
|
|
||||||
<code><![CDATA[time() + ($newTokenData['expires_in'] ?? 3600)]]></code>
|
|
||||||
</MixedArgument>
|
|
||||||
<MixedAssignment>
|
<MixedAssignment>
|
||||||
<code><![CDATA[$newTokenData]]></code>
|
<code><![CDATA[$newTokenData]]></code>
|
||||||
</MixedAssignment>
|
</MixedAssignment>
|
||||||
@@ -400,11 +395,9 @@
|
|||||||
<code><![CDATA[string|null]]></code>
|
<code><![CDATA[string|null]]></code>
|
||||||
</MixedInferredReturnType>
|
</MixedInferredReturnType>
|
||||||
<MixedOperand>
|
<MixedOperand>
|
||||||
<code><![CDATA[$newTokenData['expires_in'] ?? 3600]]></code>
|
|
||||||
<code><![CDATA[$token['expires_at']]]></code>
|
<code><![CDATA[$token['expires_at']]]></code>
|
||||||
</MixedOperand>
|
</MixedOperand>
|
||||||
<MixedReturnStatement>
|
<MixedReturnStatement>
|
||||||
<code><![CDATA[$newTokenData['access_token']]]></code>
|
|
||||||
<code><![CDATA[$token['access_token']]]></code>
|
<code><![CDATA[$token['access_token']]]></code>
|
||||||
</MixedReturnStatement>
|
</MixedReturnStatement>
|
||||||
<PossiblyUnusedMethod>
|
<PossiblyUnusedMethod>
|
||||||
|
|||||||
@@ -0,0 +1,635 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace OCA\Astrolabe\Tests\Unit\BackgroundJob;
|
||||||
|
|
||||||
|
use OCA\Astrolabe\BackgroundJob\RefreshUserTokens;
|
||||||
|
use OCA\Astrolabe\Service\IdpTokenRefresher;
|
||||||
|
use OCA\Astrolabe\Service\McpTokenStorage;
|
||||||
|
use OCP\AppFramework\Utility\ITimeFactory;
|
||||||
|
use OCP\Lock\LockedException;
|
||||||
|
use PHPUnit\Framework\MockObject\MockObject;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for RefreshUserTokens background job.
|
||||||
|
*
|
||||||
|
* Tests proactive OAuth token refresh functionality.
|
||||||
|
*/
|
||||||
|
final class RefreshUserTokensTest extends TestCase {
|
||||||
|
private ITimeFactory&MockObject $timeFactory;
|
||||||
|
private McpTokenStorage&MockObject $tokenStorage;
|
||||||
|
private IdpTokenRefresher&MockObject $tokenRefresher;
|
||||||
|
private LoggerInterface&MockObject $logger;
|
||||||
|
private RefreshUserTokens $job;
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->timeFactory = $this->createMock(ITimeFactory::class);
|
||||||
|
$this->tokenStorage = $this->createMock(McpTokenStorage::class);
|
||||||
|
$this->tokenRefresher = $this->createMock(IdpTokenRefresher::class);
|
||||||
|
$this->logger = $this->createMock(LoggerInterface::class);
|
||||||
|
|
||||||
|
$this->job = new RefreshUserTokens(
|
||||||
|
$this->timeFactory,
|
||||||
|
$this->tokenStorage,
|
||||||
|
$this->tokenRefresher,
|
||||||
|
$this->logger
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up default withTokenLock behavior that executes the callback.
|
||||||
|
* Call this in tests that need the lock to succeed.
|
||||||
|
*/
|
||||||
|
private function setupDefaultLockBehavior(): void {
|
||||||
|
$this->tokenStorage->method('withTokenLock')
|
||||||
|
->willReturnCallback(fn ($userId, $callback) => $callback());
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Constructor Tests
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public function testConstructorSetsInterval(): void {
|
||||||
|
// Use reflection to access the protected interval property
|
||||||
|
$reflection = new \ReflectionClass($this->job);
|
||||||
|
$property = $reflection->getProperty('interval');
|
||||||
|
$property->setAccessible(true);
|
||||||
|
|
||||||
|
$this->assertEquals(900, $property->getValue($this->job));
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// run() Method Tests
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public function testRunWithNoUsers(): void {
|
||||||
|
$this->tokenStorage->method('getAllUsersWithTokens')
|
||||||
|
->willReturn([]);
|
||||||
|
|
||||||
|
$this->logger->expects($this->exactly(2))
|
||||||
|
->method('info')
|
||||||
|
->willReturnCallback(function (string $message) {
|
||||||
|
static $callCount = 0;
|
||||||
|
$callCount++;
|
||||||
|
if ($callCount === 1) {
|
||||||
|
$this->assertStringContainsString('Starting', $message);
|
||||||
|
} else {
|
||||||
|
$this->assertStringContainsString('total=0', $message);
|
||||||
|
$this->assertStringContainsString('refreshed=0, failed=0, skipped=0', $message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Call run() via reflection since it's protected
|
||||||
|
$this->invokeRun();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRunWithMultipleUsersAndMixedResults(): void {
|
||||||
|
$this->setupDefaultLockBehavior();
|
||||||
|
|
||||||
|
$this->tokenStorage->method('getAllUsersWithTokens')
|
||||||
|
->willReturn(['alice', 'bob', 'charlie']);
|
||||||
|
|
||||||
|
// Alice: token with plenty of time (skipped)
|
||||||
|
// Bob: token near expiry with refresh token (refreshed)
|
||||||
|
// Charlie: token near expiry without refresh token (failed)
|
||||||
|
$this->tokenStorage->method('getUserToken')
|
||||||
|
->willReturnCallback(function (string $userId) {
|
||||||
|
$now = time();
|
||||||
|
return match ($userId) {
|
||||||
|
'alice' => [
|
||||||
|
'access_token' => 'alice-token',
|
||||||
|
'refresh_token' => 'alice-refresh',
|
||||||
|
'expires_at' => $now + 3600, // 1 hour remaining (>50% of default lifetime)
|
||||||
|
'issued_at' => $now,
|
||||||
|
],
|
||||||
|
'bob' => [
|
||||||
|
'access_token' => 'bob-token',
|
||||||
|
'refresh_token' => 'bob-refresh',
|
||||||
|
'expires_at' => $now + 100, // ~100s remaining (<50% of default lifetime)
|
||||||
|
'issued_at' => $now - 3500,
|
||||||
|
],
|
||||||
|
'charlie' => [
|
||||||
|
'access_token' => 'charlie-token',
|
||||||
|
// No refresh_token
|
||||||
|
'expires_at' => $now + 100,
|
||||||
|
'issued_at' => $now - 3500,
|
||||||
|
],
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bob's refresh should succeed
|
||||||
|
$this->tokenRefresher->method('refreshAccessToken')
|
||||||
|
->with('bob-refresh')
|
||||||
|
->willReturn([
|
||||||
|
'access_token' => 'bob-new-token',
|
||||||
|
'refresh_token' => 'bob-new-refresh',
|
||||||
|
'expires_in' => 3600,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->tokenStorage->expects($this->once())
|
||||||
|
->method('storeUserToken')
|
||||||
|
->with(
|
||||||
|
'bob',
|
||||||
|
'bob-new-token',
|
||||||
|
'bob-new-refresh',
|
||||||
|
$this->anything(),
|
||||||
|
$this->anything()
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->logger->expects($this->exactly(2))
|
||||||
|
->method('info')
|
||||||
|
->willReturnCallback(function (string $message) {
|
||||||
|
static $callCount = 0;
|
||||||
|
$callCount++;
|
||||||
|
if ($callCount === 2) {
|
||||||
|
$this->assertStringContainsString('total=3', $message);
|
||||||
|
$this->assertStringContainsString('refreshed=1, failed=1, skipped=1', $message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->invokeRun();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRunProcessesUsersInBatches(): void {
|
||||||
|
$this->setupDefaultLockBehavior();
|
||||||
|
|
||||||
|
// Simulate 150 users processed in 2 batches (100 + 50)
|
||||||
|
$batch1 = array_map(fn ($i) => "user{$i}", range(1, 100));
|
||||||
|
$batch2 = array_map(fn ($i) => "user{$i}", range(101, 150));
|
||||||
|
|
||||||
|
$callCount = 0;
|
||||||
|
$this->tokenStorage->method('getAllUsersWithTokens')
|
||||||
|
->willReturnCallback(function (int $limit, int $offset) use (&$callCount, $batch1, $batch2) {
|
||||||
|
$callCount++;
|
||||||
|
// First call: offset 0, return 100 users (full batch)
|
||||||
|
if ($offset === 0) {
|
||||||
|
$this->assertEquals(100, $limit);
|
||||||
|
return $batch1;
|
||||||
|
}
|
||||||
|
// Second call: offset 100, return 50 users (partial batch = last)
|
||||||
|
if ($offset === 100) {
|
||||||
|
$this->assertEquals(100, $limit);
|
||||||
|
return $batch2;
|
||||||
|
}
|
||||||
|
// Should not be called again
|
||||||
|
$this->fail("Unexpected getAllUsersWithTokens call with offset $offset");
|
||||||
|
});
|
||||||
|
|
||||||
|
// All tokens have plenty of time (all skipped)
|
||||||
|
$this->tokenStorage->method('getUserToken')
|
||||||
|
->willReturnCallback(function (string $userId) {
|
||||||
|
$now = time();
|
||||||
|
return [
|
||||||
|
'access_token' => "{$userId}-token",
|
||||||
|
'refresh_token' => "{$userId}-refresh",
|
||||||
|
'expires_at' => $now + 3600,
|
||||||
|
'issued_at' => $now,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->tokenRefresher->expects($this->never())
|
||||||
|
->method('refreshAccessToken');
|
||||||
|
|
||||||
|
$this->logger->expects($this->exactly(2))
|
||||||
|
->method('info')
|
||||||
|
->willReturnCallback(function (string $message) {
|
||||||
|
static $infoCallCount = 0;
|
||||||
|
$infoCallCount++;
|
||||||
|
if ($infoCallCount === 2) {
|
||||||
|
$this->assertStringContainsString('total=150', $message);
|
||||||
|
$this->assertStringContainsString('refreshed=0, failed=0, skipped=150', $message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->invokeRun();
|
||||||
|
|
||||||
|
// Verify getAllUsersWithTokens was called exactly twice (2 batches)
|
||||||
|
$this->assertEquals(2, $callCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// refreshUserTokenIfNeeded() Tests
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public function testRefreshSkippedWhenTokenHasPlentyOfTime(): void {
|
||||||
|
$now = time();
|
||||||
|
$this->tokenStorage->method('getUserToken')
|
||||||
|
->with('testuser')
|
||||||
|
->willReturn([
|
||||||
|
'access_token' => 'valid-token',
|
||||||
|
'refresh_token' => 'refresh-token',
|
||||||
|
'expires_at' => $now + 3600, // 1 hour remaining
|
||||||
|
'issued_at' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->tokenRefresher->expects($this->never())
|
||||||
|
->method('refreshAccessToken');
|
||||||
|
|
||||||
|
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||||
|
|
||||||
|
$this->assertEquals('skipped', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRefreshTriggeredWhenTokenNearExpiry(): void {
|
||||||
|
$this->setupDefaultLockBehavior();
|
||||||
|
|
||||||
|
$now = time();
|
||||||
|
$this->tokenStorage->method('getUserToken')
|
||||||
|
->with('testuser')
|
||||||
|
->willReturn([
|
||||||
|
'access_token' => 'expiring-token',
|
||||||
|
'refresh_token' => 'refresh-token',
|
||||||
|
'expires_at' => $now + 300, // 5 min remaining (< 50% of 3600s)
|
||||||
|
'issued_at' => $now - 3300, // Issued 55 min ago
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->tokenRefresher->expects($this->once())
|
||||||
|
->method('refreshAccessToken')
|
||||||
|
->with('refresh-token')
|
||||||
|
->willReturn([
|
||||||
|
'access_token' => 'new-token',
|
||||||
|
'refresh_token' => 'new-refresh-token',
|
||||||
|
'expires_in' => 3600,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->tokenStorage->expects($this->once())
|
||||||
|
->method('storeUserToken');
|
||||||
|
|
||||||
|
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||||
|
|
||||||
|
$this->assertEquals('refreshed', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRefreshFailsWhenNoRefreshToken(): void {
|
||||||
|
$this->setupDefaultLockBehavior();
|
||||||
|
|
||||||
|
$now = time();
|
||||||
|
$this->tokenStorage->method('getUserToken')
|
||||||
|
->with('testuser')
|
||||||
|
->willReturn([
|
||||||
|
'access_token' => 'expiring-token',
|
||||||
|
// No refresh_token
|
||||||
|
'expires_at' => $now + 100,
|
||||||
|
'issued_at' => $now - 3500,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->logger->expects($this->once())
|
||||||
|
->method('warning')
|
||||||
|
->with($this->stringContains('no refresh token'));
|
||||||
|
|
||||||
|
$this->tokenRefresher->expects($this->never())
|
||||||
|
->method('refreshAccessToken');
|
||||||
|
|
||||||
|
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||||
|
|
||||||
|
$this->assertEquals('failed', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRefreshFailsWhenRefresherReturnsNull(): void {
|
||||||
|
$this->setupDefaultLockBehavior();
|
||||||
|
|
||||||
|
$now = time();
|
||||||
|
$this->tokenStorage->method('getUserToken')
|
||||||
|
->with('testuser')
|
||||||
|
->willReturn([
|
||||||
|
'access_token' => 'expiring-token',
|
||||||
|
'refresh_token' => 'invalid-refresh',
|
||||||
|
'expires_at' => $now + 100,
|
||||||
|
'issued_at' => $now - 3500,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->tokenRefresher->expects($this->once())
|
||||||
|
->method('refreshAccessToken')
|
||||||
|
->with('invalid-refresh')
|
||||||
|
->willReturn(null);
|
||||||
|
|
||||||
|
$this->logger->expects($this->once())
|
||||||
|
->method('warning')
|
||||||
|
->with($this->stringContains('Refresh returned null'));
|
||||||
|
|
||||||
|
// Should NOT delete token - let on-demand refresh handle cleanup
|
||||||
|
$this->tokenStorage->expects($this->never())
|
||||||
|
->method('deleteUserToken');
|
||||||
|
|
||||||
|
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||||
|
|
||||||
|
$this->assertEquals('failed', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRefreshUsesIssuedAtForLifetimeCalculation(): void {
|
||||||
|
$this->setupDefaultLockBehavior();
|
||||||
|
|
||||||
|
$now = time();
|
||||||
|
// Token with custom lifetime: issued 50 min ago, expires in 10 min (total 60 min)
|
||||||
|
// 10/60 = 16.7% remaining, which is < 50%, so should refresh
|
||||||
|
$this->tokenStorage->method('getUserToken')
|
||||||
|
->with('testuser')
|
||||||
|
->willReturn([
|
||||||
|
'access_token' => 'token',
|
||||||
|
'refresh_token' => 'refresh',
|
||||||
|
'expires_at' => $now + 600, // 10 min remaining
|
||||||
|
'issued_at' => $now - 3000, // 50 min ago, total lifetime 60 min
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->tokenRefresher->expects($this->once())
|
||||||
|
->method('refreshAccessToken')
|
||||||
|
->willReturn([
|
||||||
|
'access_token' => 'new-token',
|
||||||
|
'refresh_token' => 'new-refresh',
|
||||||
|
'expires_in' => 3600,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||||
|
|
||||||
|
$this->assertEquals('refreshed', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRefreshUsesDefaultLifetimeWhenNoIssuedAt(): void {
|
||||||
|
$this->setupDefaultLockBehavior();
|
||||||
|
|
||||||
|
$now = time();
|
||||||
|
// Token without issued_at, uses default 3600s lifetime
|
||||||
|
// 300s remaining / 3600s = 8.3% remaining, which is < 50%, so should refresh
|
||||||
|
$this->tokenStorage->method('getUserToken')
|
||||||
|
->with('testuser')
|
||||||
|
->willReturn([
|
||||||
|
'access_token' => 'token',
|
||||||
|
'refresh_token' => 'refresh',
|
||||||
|
'expires_at' => $now + 300, // 5 min remaining
|
||||||
|
// No issued_at
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->tokenRefresher->expects($this->once())
|
||||||
|
->method('refreshAccessToken')
|
||||||
|
->willReturn([
|
||||||
|
'access_token' => 'new-token',
|
||||||
|
'refresh_token' => 'new-refresh',
|
||||||
|
'expires_in' => 3600,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||||
|
|
||||||
|
$this->assertEquals('refreshed', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRefreshStoresNewTokenWithIssuedAt(): void {
|
||||||
|
$this->setupDefaultLockBehavior();
|
||||||
|
|
||||||
|
$now = time();
|
||||||
|
$this->tokenStorage->method('getUserToken')
|
||||||
|
->with('testuser')
|
||||||
|
->willReturn([
|
||||||
|
'access_token' => 'old-token',
|
||||||
|
'refresh_token' => 'old-refresh',
|
||||||
|
'expires_at' => $now + 100,
|
||||||
|
'issued_at' => $now - 3500,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->tokenRefresher->expects($this->once())
|
||||||
|
->method('refreshAccessToken')
|
||||||
|
->willReturn([
|
||||||
|
'access_token' => 'new-token',
|
||||||
|
'refresh_token' => 'new-refresh',
|
||||||
|
'expires_in' => 3600,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify storeUserToken is called with issued_at parameter
|
||||||
|
$this->tokenStorage->expects($this->once())
|
||||||
|
->method('storeUserToken')
|
||||||
|
->with(
|
||||||
|
'testuser',
|
||||||
|
'new-token',
|
||||||
|
'new-refresh',
|
||||||
|
$this->greaterThan($now), // expires_at = now + 3600
|
||||||
|
$this->greaterThanOrEqual($now) // issued_at = now
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||||
|
|
||||||
|
$this->assertEquals('refreshed', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRefreshKeepsOldRefreshTokenIfNotRotated(): void {
|
||||||
|
$this->setupDefaultLockBehavior();
|
||||||
|
|
||||||
|
$now = time();
|
||||||
|
$this->tokenStorage->method('getUserToken')
|
||||||
|
->with('testuser')
|
||||||
|
->willReturn([
|
||||||
|
'access_token' => 'old-token',
|
||||||
|
'refresh_token' => 'original-refresh',
|
||||||
|
'expires_at' => $now + 100,
|
||||||
|
'issued_at' => $now - 3500,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// IdP returns new access token but no new refresh token (no rotation)
|
||||||
|
$this->tokenRefresher->expects($this->once())
|
||||||
|
->method('refreshAccessToken')
|
||||||
|
->willReturn([
|
||||||
|
'access_token' => 'new-token',
|
||||||
|
// No refresh_token in response
|
||||||
|
'expires_in' => 3600,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Should use the original refresh token
|
||||||
|
$this->tokenStorage->expects($this->once())
|
||||||
|
->method('storeUserToken')
|
||||||
|
->with(
|
||||||
|
'testuser',
|
||||||
|
'new-token',
|
||||||
|
'original-refresh', // Original refresh token preserved
|
||||||
|
$this->anything(),
|
||||||
|
$this->anything()
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||||
|
|
||||||
|
$this->assertEquals('refreshed', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRefreshHandlesException(): void {
|
||||||
|
$this->setupDefaultLockBehavior();
|
||||||
|
|
||||||
|
$now = time();
|
||||||
|
$this->tokenStorage->method('getUserToken')
|
||||||
|
->with('testuser')
|
||||||
|
->willReturn([
|
||||||
|
'access_token' => 'token',
|
||||||
|
'refresh_token' => 'refresh',
|
||||||
|
'expires_at' => $now + 100,
|
||||||
|
'issued_at' => $now - 3500,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->tokenRefresher->expects($this->once())
|
||||||
|
->method('refreshAccessToken')
|
||||||
|
->willThrowException(new \Exception('Network error'));
|
||||||
|
|
||||||
|
$this->logger->expects($this->once())
|
||||||
|
->method('error')
|
||||||
|
->with($this->stringContains('Failed to refresh'));
|
||||||
|
|
||||||
|
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||||
|
|
||||||
|
$this->assertEquals('failed', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRefreshSkippedWhenNoToken(): void {
|
||||||
|
$this->tokenStorage->method('getUserToken')
|
||||||
|
->with('testuser')
|
||||||
|
->willReturn(null);
|
||||||
|
|
||||||
|
$this->tokenRefresher->expects($this->never())
|
||||||
|
->method('refreshAccessToken');
|
||||||
|
|
||||||
|
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||||
|
|
||||||
|
$this->assertEquals('skipped', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Locking Tests
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public function testRefreshSkippedWhenLockCannotBeAcquired(): void {
|
||||||
|
$now = time();
|
||||||
|
$this->tokenStorage->method('getUserToken')
|
||||||
|
->with('testuser')
|
||||||
|
->willReturn([
|
||||||
|
'access_token' => 'expiring-token',
|
||||||
|
'refresh_token' => 'refresh-token',
|
||||||
|
'expires_at' => $now + 100, // ~100s remaining (< 50% of default)
|
||||||
|
'issued_at' => $now - 3500,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Lock acquisition fails (on-demand refresh is holding it)
|
||||||
|
$this->tokenStorage->expects($this->once())
|
||||||
|
->method('withTokenLock')
|
||||||
|
->willThrowException(new LockedException('astrolabe/oauth/tokens/testuser'));
|
||||||
|
|
||||||
|
// Token refresher should NOT be called when lock fails
|
||||||
|
$this->tokenRefresher->expects($this->never())
|
||||||
|
->method('refreshAccessToken');
|
||||||
|
|
||||||
|
$this->logger->expects($this->once())
|
||||||
|
->method('debug')
|
||||||
|
->with($this->stringContains('Lock held for user testuser'));
|
||||||
|
|
||||||
|
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||||
|
|
||||||
|
$this->assertEquals('skipped', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRefreshUsesLockForTokenRefresh(): void {
|
||||||
|
$now = time();
|
||||||
|
$this->tokenStorage->method('getUserToken')
|
||||||
|
->with('testuser')
|
||||||
|
->willReturn([
|
||||||
|
'access_token' => 'expiring-token',
|
||||||
|
'refresh_token' => 'refresh-token',
|
||||||
|
'expires_at' => $now + 100,
|
||||||
|
'issued_at' => $now - 3500,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// withTokenLock is called and executes the callback
|
||||||
|
$this->tokenStorage->expects($this->once())
|
||||||
|
->method('withTokenLock')
|
||||||
|
->with('testuser', $this->isInstanceOf(\Closure::class))
|
||||||
|
->willReturnCallback(function ($userId, $callback) {
|
||||||
|
return $callback();
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->tokenRefresher->expects($this->once())
|
||||||
|
->method('refreshAccessToken')
|
||||||
|
->with('refresh-token')
|
||||||
|
->willReturn([
|
||||||
|
'access_token' => 'new-token',
|
||||||
|
'refresh_token' => 'new-refresh-token',
|
||||||
|
'expires_in' => 3600,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->tokenStorage->expects($this->once())
|
||||||
|
->method('storeUserToken');
|
||||||
|
|
||||||
|
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||||
|
|
||||||
|
$this->assertEquals('refreshed', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRefreshSkippedWhenTokenAlreadyRefreshedWhileWaitingForLock(): void {
|
||||||
|
$now = time();
|
||||||
|
|
||||||
|
// First call (before lock): token is expiring
|
||||||
|
// Calls inside lock callback: token is now fresh
|
||||||
|
$callCount = 0;
|
||||||
|
$this->tokenStorage->method('getUserToken')
|
||||||
|
->with('testuser')
|
||||||
|
->willReturnCallback(function () use (&$callCount, $now) {
|
||||||
|
$callCount++;
|
||||||
|
if ($callCount === 1) {
|
||||||
|
// First check: token is expiring
|
||||||
|
return [
|
||||||
|
'access_token' => 'expiring-token',
|
||||||
|
'refresh_token' => 'refresh-token',
|
||||||
|
'expires_at' => $now + 100,
|
||||||
|
'issued_at' => $now - 3500,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
// Inside lock: token was already refreshed
|
||||||
|
return [
|
||||||
|
'access_token' => 'already-refreshed-token',
|
||||||
|
'refresh_token' => 'new-refresh-token',
|
||||||
|
'expires_at' => $now + 3600, // Fresh token
|
||||||
|
'issued_at' => $now,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
// withTokenLock is called and executes the callback
|
||||||
|
$this->tokenStorage->expects($this->once())
|
||||||
|
->method('withTokenLock')
|
||||||
|
->willReturnCallback(function ($userId, $callback) {
|
||||||
|
return $callback();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Token refresher should NOT be called since token is already fresh
|
||||||
|
$this->tokenRefresher->expects($this->never())
|
||||||
|
->method('refreshAccessToken');
|
||||||
|
|
||||||
|
$this->logger->expects($this->once())
|
||||||
|
->method('debug')
|
||||||
|
->with($this->stringContains('already refreshed'));
|
||||||
|
|
||||||
|
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||||
|
|
||||||
|
$this->assertEquals('skipped', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Helper Methods
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoke the protected run() method.
|
||||||
|
*/
|
||||||
|
private function invokeRun(): void {
|
||||||
|
$reflection = new \ReflectionClass($this->job);
|
||||||
|
$method = $reflection->getMethod('run');
|
||||||
|
$method->setAccessible(true);
|
||||||
|
$method->invoke($this->job, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoke the private refreshUserTokenIfNeeded() method.
|
||||||
|
*/
|
||||||
|
private function invokeRefreshUserTokenIfNeeded(string $userId): string {
|
||||||
|
$reflection = new \ReflectionClass($this->job);
|
||||||
|
$method = $reflection->getMethod('refreshUserTokenIfNeeded');
|
||||||
|
$method->setAccessible(true);
|
||||||
|
return $method->invoke($this->job, $userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,13 @@ declare(strict_types=1);
|
|||||||
namespace OCA\Astrolabe\Tests\Unit\Service;
|
namespace OCA\Astrolabe\Tests\Unit\Service;
|
||||||
|
|
||||||
use OCA\Astrolabe\Service\McpTokenStorage;
|
use OCA\Astrolabe\Service\McpTokenStorage;
|
||||||
|
use OCP\DB\IResult;
|
||||||
|
use OCP\DB\QueryBuilder\IExpressionBuilder;
|
||||||
|
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||||
use OCP\IConfig;
|
use OCP\IConfig;
|
||||||
|
use OCP\IDBConnection;
|
||||||
|
use OCP\Lock\ILockingProvider;
|
||||||
|
use OCP\Lock\LockedException;
|
||||||
use OCP\Security\ICrypto;
|
use OCP\Security\ICrypto;
|
||||||
use PHPUnit\Framework\MockObject\MockObject;
|
use PHPUnit\Framework\MockObject\MockObject;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
@@ -19,7 +25,9 @@ use Psr\Log\LoggerInterface;
|
|||||||
final class McpTokenStorageTest extends TestCase {
|
final class McpTokenStorageTest extends TestCase {
|
||||||
private IConfig&MockObject $config;
|
private IConfig&MockObject $config;
|
||||||
private ICrypto&MockObject $crypto;
|
private ICrypto&MockObject $crypto;
|
||||||
|
private IDBConnection&MockObject $db;
|
||||||
private LoggerInterface&MockObject $logger;
|
private LoggerInterface&MockObject $logger;
|
||||||
|
private ILockingProvider&MockObject $lockingProvider;
|
||||||
private McpTokenStorage $storage;
|
private McpTokenStorage $storage;
|
||||||
|
|
||||||
protected function setUp(): void {
|
protected function setUp(): void {
|
||||||
@@ -27,12 +35,16 @@ final class McpTokenStorageTest extends TestCase {
|
|||||||
|
|
||||||
$this->config = $this->createMock(IConfig::class);
|
$this->config = $this->createMock(IConfig::class);
|
||||||
$this->crypto = $this->createMock(ICrypto::class);
|
$this->crypto = $this->createMock(ICrypto::class);
|
||||||
|
$this->db = $this->createMock(IDBConnection::class);
|
||||||
$this->logger = $this->createMock(LoggerInterface::class);
|
$this->logger = $this->createMock(LoggerInterface::class);
|
||||||
|
$this->lockingProvider = $this->createMock(ILockingProvider::class);
|
||||||
|
|
||||||
$this->storage = new McpTokenStorage(
|
$this->storage = new McpTokenStorage(
|
||||||
$this->config,
|
$this->config,
|
||||||
$this->crypto,
|
$this->crypto,
|
||||||
$this->logger
|
$this->db,
|
||||||
|
$this->logger,
|
||||||
|
$this->lockingProvider
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,15 +58,15 @@ final class McpTokenStorageTest extends TestCase {
|
|||||||
$refreshToken = 'refresh-token-456';
|
$refreshToken = 'refresh-token-456';
|
||||||
$expiresAt = time() + 3600;
|
$expiresAt = time() + 3600;
|
||||||
|
|
||||||
$expectedTokenData = [
|
|
||||||
'access_token' => $accessToken,
|
|
||||||
'refresh_token' => $refreshToken,
|
|
||||||
'expires_at' => $expiresAt,
|
|
||||||
];
|
|
||||||
|
|
||||||
$this->crypto->expects($this->once())
|
$this->crypto->expects($this->once())
|
||||||
->method('encrypt')
|
->method('encrypt')
|
||||||
->with(json_encode($expectedTokenData))
|
->with($this->callback(function (string $json) use ($accessToken, $refreshToken, $expiresAt) {
|
||||||
|
$data = json_decode($json, true);
|
||||||
|
return $data['access_token'] === $accessToken
|
||||||
|
&& $data['refresh_token'] === $refreshToken
|
||||||
|
&& $data['expires_at'] === $expiresAt
|
||||||
|
&& isset($data['issued_at']); // issued_at should be set (defaults to time())
|
||||||
|
}))
|
||||||
->willReturn('encrypted-data');
|
->willReturn('encrypted-data');
|
||||||
|
|
||||||
$this->config->expects($this->once())
|
$this->config->expects($this->once())
|
||||||
@@ -284,6 +296,155 @@ final class McpTokenStorageTest extends TestCase {
|
|||||||
$this->assertNull($result);
|
$this->assertNull($result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Token Refresh Locking Tests
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public function testGetAccessTokenAcquiresLockWhenRefreshing(): void {
|
||||||
|
$userId = 'testuser';
|
||||||
|
$expiredTokenData = [
|
||||||
|
'access_token' => 'expired-access-token',
|
||||||
|
'refresh_token' => 'old-refresh-token',
|
||||||
|
'expires_at' => time() - 100, // Expired
|
||||||
|
];
|
||||||
|
|
||||||
|
$newTokenData = [
|
||||||
|
'access_token' => 'new-access-token',
|
||||||
|
'refresh_token' => 'new-refresh-token',
|
||||||
|
'expires_in' => 3600,
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->config->method('getUserValue')
|
||||||
|
->willReturn('encrypted-data');
|
||||||
|
|
||||||
|
$this->crypto->method('decrypt')
|
||||||
|
->willReturn(json_encode($expiredTokenData));
|
||||||
|
|
||||||
|
$this->crypto->method('encrypt')
|
||||||
|
->willReturn('new-encrypted-data');
|
||||||
|
|
||||||
|
// Verify lock is acquired and released
|
||||||
|
$this->lockingProvider->expects($this->once())
|
||||||
|
->method('acquireLock')
|
||||||
|
->with('astrolabe/oauth/tokens/testuser', ILockingProvider::LOCK_EXCLUSIVE);
|
||||||
|
|
||||||
|
$this->lockingProvider->expects($this->once())
|
||||||
|
->method('releaseLock')
|
||||||
|
->with('astrolabe/oauth/tokens/testuser', ILockingProvider::LOCK_EXCLUSIVE);
|
||||||
|
|
||||||
|
$refreshCallback = fn (string $refreshToken) => $newTokenData;
|
||||||
|
|
||||||
|
$result = $this->storage->getAccessToken($userId, $refreshCallback);
|
||||||
|
|
||||||
|
$this->assertEquals('new-access-token', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetAccessTokenReturnsStaleTokenOnLockedException(): void {
|
||||||
|
$userId = 'testuser';
|
||||||
|
$expiredTokenData = [
|
||||||
|
'access_token' => 'expired-access-token',
|
||||||
|
'refresh_token' => 'old-refresh-token',
|
||||||
|
'expires_at' => time() - 100, // Expired
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->config->method('getUserValue')
|
||||||
|
->willReturn('encrypted-data');
|
||||||
|
|
||||||
|
$this->crypto->method('decrypt')
|
||||||
|
->willReturn(json_encode($expiredTokenData));
|
||||||
|
|
||||||
|
// Lock acquisition fails
|
||||||
|
$this->lockingProvider->expects($this->once())
|
||||||
|
->method('acquireLock')
|
||||||
|
->willThrowException(new LockedException('astrolabe/oauth/tokens/testuser'));
|
||||||
|
|
||||||
|
// Refresh callback should NOT be called when lock fails
|
||||||
|
$refreshCallbackCalled = false;
|
||||||
|
$refreshCallback = function (string $refreshToken) use (&$refreshCallbackCalled) {
|
||||||
|
$refreshCallbackCalled = true;
|
||||||
|
return ['access_token' => 'new-token', 'expires_in' => 3600];
|
||||||
|
};
|
||||||
|
|
||||||
|
$result = $this->storage->getAccessToken($userId, $refreshCallback);
|
||||||
|
|
||||||
|
// Should return stale token instead of failing
|
||||||
|
$this->assertEquals('expired-access-token', $result);
|
||||||
|
$this->assertFalse($refreshCallbackCalled);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetAccessTokenSkipsRefreshWhenTokenAlreadyRefreshedWhileWaitingForLock(): void {
|
||||||
|
$userId = 'testuser';
|
||||||
|
$expiredTokenData = [
|
||||||
|
'access_token' => 'expired-access-token',
|
||||||
|
'refresh_token' => 'old-refresh-token',
|
||||||
|
'expires_at' => time() - 100, // Expired
|
||||||
|
];
|
||||||
|
|
||||||
|
// After lock is acquired, token appears fresh (another process refreshed it)
|
||||||
|
$freshTokenData = [
|
||||||
|
'access_token' => 'fresh-access-token',
|
||||||
|
'refresh_token' => 'fresh-refresh-token',
|
||||||
|
'expires_at' => time() + 3600, // Valid for 1 hour
|
||||||
|
];
|
||||||
|
|
||||||
|
$callCount = 0;
|
||||||
|
$this->config->method('getUserValue')
|
||||||
|
->willReturn('encrypted-data');
|
||||||
|
|
||||||
|
// First call returns expired, subsequent calls return fresh
|
||||||
|
$this->crypto->method('decrypt')
|
||||||
|
->willReturnCallback(function () use (&$callCount, $expiredTokenData, $freshTokenData) {
|
||||||
|
$callCount++;
|
||||||
|
return $callCount === 1
|
||||||
|
? json_encode($expiredTokenData)
|
||||||
|
: json_encode($freshTokenData);
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->lockingProvider->expects($this->once())
|
||||||
|
->method('acquireLock');
|
||||||
|
|
||||||
|
$this->lockingProvider->expects($this->once())
|
||||||
|
->method('releaseLock');
|
||||||
|
|
||||||
|
// Refresh callback should NOT be called since token is already fresh
|
||||||
|
$refreshCallbackCalled = false;
|
||||||
|
$refreshCallback = function (string $refreshToken) use (&$refreshCallbackCalled) {
|
||||||
|
$refreshCallbackCalled = true;
|
||||||
|
return ['access_token' => 'new-token', 'expires_in' => 3600];
|
||||||
|
};
|
||||||
|
|
||||||
|
$result = $this->storage->getAccessToken($userId, $refreshCallback);
|
||||||
|
|
||||||
|
$this->assertEquals('fresh-access-token', $result);
|
||||||
|
$this->assertFalse($refreshCallbackCalled);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetAccessTokenNoLockRequiredWhenNotExpired(): void {
|
||||||
|
$userId = 'testuser';
|
||||||
|
$validTokenData = [
|
||||||
|
'access_token' => 'valid-access-token',
|
||||||
|
'refresh_token' => 'refresh-token',
|
||||||
|
'expires_at' => time() + 3600, // Valid for 1 hour
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->config->method('getUserValue')
|
||||||
|
->willReturn('encrypted-data');
|
||||||
|
|
||||||
|
$this->crypto->method('decrypt')
|
||||||
|
->willReturn(json_encode($validTokenData));
|
||||||
|
|
||||||
|
// Lock should NOT be acquired for valid tokens
|
||||||
|
$this->lockingProvider->expects($this->never())
|
||||||
|
->method('acquireLock');
|
||||||
|
|
||||||
|
$this->lockingProvider->expects($this->never())
|
||||||
|
->method('releaseLock');
|
||||||
|
|
||||||
|
$result = $this->storage->getAccessToken($userId);
|
||||||
|
|
||||||
|
$this->assertEquals('valid-access-token', $result);
|
||||||
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// App Password Storage Tests (Multi-User Basic Auth)
|
// App Password Storage Tests (Multi-User Basic Auth)
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -524,4 +685,145 @@ final class McpTokenStorageTest extends TestCase {
|
|||||||
|
|
||||||
$this->assertNull($result);
|
$this->assertNull($result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// getAllUsersWithTokens Tests
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public function testGetAllUsersWithTokensReturnsUserIds(): void {
|
||||||
|
$qb = $this->createMock(IQueryBuilder::class);
|
||||||
|
$expr = $this->createMock(IExpressionBuilder::class);
|
||||||
|
$result = $this->createMock(IResult::class);
|
||||||
|
|
||||||
|
// Chain builder methods
|
||||||
|
$qb->method('select')->willReturnSelf();
|
||||||
|
$qb->method('from')->willReturnSelf();
|
||||||
|
$qb->method('where')->willReturnSelf();
|
||||||
|
$qb->method('andWhere')->willReturnSelf();
|
||||||
|
$qb->method('expr')->willReturn($expr);
|
||||||
|
$qb->method('createNamedParameter')->willReturnArgument(0);
|
||||||
|
$qb->method('executeQuery')->willReturn($result);
|
||||||
|
|
||||||
|
// Mock expression builder
|
||||||
|
$expr->method('eq')->willReturn('mocked_condition');
|
||||||
|
|
||||||
|
// Mock result set with multiple users
|
||||||
|
$result->method('fetch')->willReturnOnConsecutiveCalls(
|
||||||
|
['userid' => 'admin'],
|
||||||
|
['userid' => 'alice'],
|
||||||
|
['userid' => 'bob'],
|
||||||
|
false // End of results
|
||||||
|
);
|
||||||
|
$result->expects($this->once())->method('closeCursor');
|
||||||
|
|
||||||
|
$this->db->method('getQueryBuilder')->willReturn($qb);
|
||||||
|
|
||||||
|
$userIds = $this->storage->getAllUsersWithTokens();
|
||||||
|
|
||||||
|
$this->assertEquals(['admin', 'alice', 'bob'], $userIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetAllUsersWithTokensReturnsEmptyArrayWhenNoTokens(): void {
|
||||||
|
$qb = $this->createMock(IQueryBuilder::class);
|
||||||
|
$expr = $this->createMock(IExpressionBuilder::class);
|
||||||
|
$result = $this->createMock(IResult::class);
|
||||||
|
|
||||||
|
// Chain builder methods
|
||||||
|
$qb->method('select')->willReturnSelf();
|
||||||
|
$qb->method('from')->willReturnSelf();
|
||||||
|
$qb->method('where')->willReturnSelf();
|
||||||
|
$qb->method('andWhere')->willReturnSelf();
|
||||||
|
$qb->method('expr')->willReturn($expr);
|
||||||
|
$qb->method('createNamedParameter')->willReturnArgument(0);
|
||||||
|
$qb->method('executeQuery')->willReturn($result);
|
||||||
|
|
||||||
|
// Mock expression builder
|
||||||
|
$expr->method('eq')->willReturn('mocked_condition');
|
||||||
|
|
||||||
|
// Mock empty result set
|
||||||
|
$result->method('fetch')->willReturn(false);
|
||||||
|
$result->expects($this->once())->method('closeCursor');
|
||||||
|
|
||||||
|
$this->db->method('getQueryBuilder')->willReturn($qb);
|
||||||
|
|
||||||
|
$userIds = $this->storage->getAllUsersWithTokens();
|
||||||
|
|
||||||
|
$this->assertEquals([], $userIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetAllUsersWithTokensWithLimitAndOffset(): void {
|
||||||
|
$qb = $this->createMock(IQueryBuilder::class);
|
||||||
|
$expr = $this->createMock(IExpressionBuilder::class);
|
||||||
|
$result = $this->createMock(IResult::class);
|
||||||
|
|
||||||
|
// Chain builder methods
|
||||||
|
$qb->method('select')->willReturnSelf();
|
||||||
|
$qb->method('from')->willReturnSelf();
|
||||||
|
$qb->method('where')->willReturnSelf();
|
||||||
|
$qb->method('andWhere')->willReturnSelf();
|
||||||
|
$qb->method('expr')->willReturn($expr);
|
||||||
|
$qb->method('createNamedParameter')->willReturnArgument(0);
|
||||||
|
$qb->method('executeQuery')->willReturn($result);
|
||||||
|
|
||||||
|
// Verify setMaxResults and setFirstResult are called with correct values
|
||||||
|
$qb->expects($this->once())
|
||||||
|
->method('setMaxResults')
|
||||||
|
->with(50)
|
||||||
|
->willReturnSelf();
|
||||||
|
$qb->expects($this->once())
|
||||||
|
->method('setFirstResult')
|
||||||
|
->with(100)
|
||||||
|
->willReturnSelf();
|
||||||
|
|
||||||
|
// Mock expression builder
|
||||||
|
$expr->method('eq')->willReturn('mocked_condition');
|
||||||
|
|
||||||
|
// Mock result set
|
||||||
|
$result->method('fetch')->willReturnOnConsecutiveCalls(
|
||||||
|
['userid' => 'user1'],
|
||||||
|
['userid' => 'user2'],
|
||||||
|
false
|
||||||
|
);
|
||||||
|
$result->expects($this->once())->method('closeCursor');
|
||||||
|
|
||||||
|
$this->db->method('getQueryBuilder')->willReturn($qb);
|
||||||
|
|
||||||
|
$userIds = $this->storage->getAllUsersWithTokens(50, 100);
|
||||||
|
|
||||||
|
$this->assertEquals(['user1', 'user2'], $userIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetAllUsersWithTokensWithZeroLimitDoesNotSetMaxResults(): void {
|
||||||
|
$qb = $this->createMock(IQueryBuilder::class);
|
||||||
|
$expr = $this->createMock(IExpressionBuilder::class);
|
||||||
|
$result = $this->createMock(IResult::class);
|
||||||
|
|
||||||
|
// Chain builder methods
|
||||||
|
$qb->method('select')->willReturnSelf();
|
||||||
|
$qb->method('from')->willReturnSelf();
|
||||||
|
$qb->method('where')->willReturnSelf();
|
||||||
|
$qb->method('andWhere')->willReturnSelf();
|
||||||
|
$qb->method('expr')->willReturn($expr);
|
||||||
|
$qb->method('createNamedParameter')->willReturnArgument(0);
|
||||||
|
$qb->method('executeQuery')->willReturn($result);
|
||||||
|
|
||||||
|
// setMaxResults should NOT be called when limit is 0
|
||||||
|
$qb->expects($this->never())
|
||||||
|
->method('setMaxResults');
|
||||||
|
|
||||||
|
// setFirstResult should NOT be called when offset is 0
|
||||||
|
$qb->expects($this->never())
|
||||||
|
->method('setFirstResult');
|
||||||
|
|
||||||
|
// Mock expression builder
|
||||||
|
$expr->method('eq')->willReturn('mocked_condition');
|
||||||
|
|
||||||
|
// Mock result set
|
||||||
|
$result->method('fetch')->willReturn(false);
|
||||||
|
$result->expects($this->once())->method('closeCursor');
|
||||||
|
|
||||||
|
$this->db->method('getQueryBuilder')->willReturn($qb);
|
||||||
|
|
||||||
|
$this->storage->getAllUsersWithTokens(0, 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1988,7 +1988,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nextcloud-mcp-server"
|
name = "nextcloud-mcp-server"
|
||||||
version = "0.61.5"
|
version = "0.62.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiosqlite" },
|
{ name = "aiosqlite" },
|
||||||
|
|||||||
Reference in New Issue
Block a user