fix(auth): Skip issuer validation for management API tokens

Fixes NC PHP app (Astrolabe) OAuth integration by making token validation
more lenient for management API access.

Problem:
- Astrolabe calls Nextcloud OIDC token endpoint via internal URL (http://localhost)
- Tokens are issued with iss: http://localhost (internal)
- MCP server expects iss: http://localhost:8080 (external)
- Token validation failed with "Invalid issuer"

Solution:
- Add skip_issuer_check parameter to _verify_jwt_signature()
- verify_token_for_management_api() now skips both audience and issuer checks
- Security maintained: signature still verified, authorization checked by API

Also includes related fixes from previous session:
- Update test selectors for Vue 3 UI ("Enable Semantic Search")
- Fix OIDC discovery URL transformation in OAuthController.php
- Add overwrite.cli.url to setup hook for proper external URLs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2025-12-24 17:25:48 -06:00
co-authored by Claude Opus 4.5
parent 5e2ef5f35b
commit 804480836e
6 changed files with 249 additions and 78 deletions
@@ -4,7 +4,8 @@ set -euox pipefail
php /var/www/html/occ config:system:set trusted_domains 2 --value=host.docker.internal php /var/www/html/occ config:system:set trusted_domains 2 --value=host.docker.internal
# Do NOT set overwritehost/overwrite.cli.url - let Nextcloud use the request's Host header # Set overwrite.cli.url to the external URL for OIDC discovery
# This allows: # This ensures OAuth flows redirect to the correct external URL
# - Browser requests to localhost:8080 → returns localhost:8080 URLs # Important: The Astrolabe OAuth controller makes internal HTTP requests to /.well-known/openid-configuration
# - Container requests to app:80 → returns app:80 URLs (for DCR, token exchange, etc.) # which needs to return URLs reachable by external browsers (localhost:8080, not localhost:80)
php /var/www/html/occ config:system:set overwrite.cli.url --value="http://localhost:8080"
+8 -3
View File
@@ -54,6 +54,10 @@ async def validate_token_and_get_user(
) -> tuple[str, dict[str, Any]]: ) -> tuple[str, dict[str, Any]]:
"""Validate OAuth bearer token and extract user ID. """Validate OAuth bearer token and extract user ID.
Uses verify_token_for_management_api which accepts any valid Nextcloud OIDC
token (not just MCP-audience tokens). This is needed because Astrolabe
(NC PHP app) uses its own OAuth client, separate from MCP server's client.
Args: Args:
request: Starlette request with Authorization header request: Starlette request with Authorization header
@@ -71,9 +75,10 @@ async def validate_token_and_get_user(
# Note: This is set in app.py starlette_lifespan for OAuth mode # Note: This is set in app.py starlette_lifespan for OAuth mode
token_verifier = request.app.state.oauth_context["token_verifier"] token_verifier = request.app.state.oauth_context["token_verifier"]
# Validate token (handles both JWT and opaque tokens) # Validate token for management API (handles both JWT and opaque tokens)
# verify_token returns AccessToken object or None # Uses verify_token_for_management_api which accepts any valid Nextcloud token
access_token = await token_verifier.verify_token(token) # without requiring MCP audience - needed for Astrolabe integration (ADR-018)
access_token = await token_verifier.verify_token_for_management_api(token)
if not access_token: if not access_token:
raise ValueError("Token validation failed") raise ValueError("Token validation failed")
+142 -15
View File
@@ -117,6 +117,51 @@ class UnifiedTokenVerifier(TokenVerifier):
# Both modes do the same validation (MCP audience only) # Both modes do the same validation (MCP audience only)
return await self._verify_mcp_audience(token) return await self._verify_mcp_audience(token)
async def verify_token_for_management_api(self, token: str) -> AccessToken | None:
"""
Verify token for management API access (ADR-018 NC PHP app integration).
This is a more lenient verification that accepts ANY valid Nextcloud OIDC
token, not just tokens with MCP server audience. This is needed because:
- Astrolabe (NC PHP app) uses its own OAuth client with Nextcloud OIDC
- Tokens from Astrolabe have Astrolabe's client_id as audience
- MCP server's management API should accept these tokens
Security model:
- Authentication: Token is valid (issued by Nextcloud, not expired)
- Authorization: Token's user == requested resource (checked by management API)
Args:
token: Bearer token to verify
Returns:
AccessToken if valid (regardless of audience), None otherwise
"""
# Check cache first (using separate cache key to avoid mixing with MCP tokens)
cache_key = f"mgmt:{hashlib.sha256(token.encode()).hexdigest()}"
if cache_key in self._token_cache:
userinfo, expiry = self._token_cache[cache_key]
if time.time() < expiry:
logger.debug("Management API token found in cache")
oauth_token_cache_hits_total.labels(hit="true").inc()
username = userinfo.get("sub") or userinfo.get("preferred_username")
scope_string = userinfo.get("scope", "")
scopes = scope_string.split() if scope_string else []
return AccessToken(
token=token,
client_id=userinfo.get("client_id", ""),
scopes=scopes,
expires_at=int(expiry),
resource=username,
)
else:
del self._token_cache[cache_key]
oauth_token_cache_hits_total.labels(hit="false").inc()
# Verify token without audience check
return await self._verify_without_audience_check(token, cache_key)
async def _verify_mcp_audience(self, token: str) -> AccessToken | None: async def _verify_mcp_audience(self, token: str) -> AccessToken | None:
""" """
Validate token has MCP audience. Validate token has MCP audience.
@@ -186,6 +231,71 @@ class UnifiedTokenVerifier(TokenVerifier):
record_oauth_token_validation(validation_method, "error") record_oauth_token_validation(validation_method, "error")
return None return None
async def _verify_without_audience_check(
self, token: str, cache_key: str
) -> AccessToken | None:
"""
Verify token validity without checking MCP audience or issuer.
Used for management API where tokens from Astrolabe (NC PHP app) need to
be accepted. These tokens are issued by Nextcloud OIDC to Astrolabe's
OAuth client, not MCP server's client.
Security model:
- We skip audience check (token may have Astrolabe's audience, not MCP's)
- We skip issuer check (token may have internal Nextcloud URL as issuer)
- We still verify signature (token is authentically from Nextcloud OIDC)
- We still verify expiration (token is not expired)
- Authorization is checked by management API (user == requested resource)
Args:
token: Bearer token to verify
cache_key: Cache key for storing validation result
Returns:
AccessToken if valid, None otherwise
"""
validation_method = "unknown"
try:
# Attempt JWT verification first
# Skip issuer check for management API tokens (may have internal URL)
if self._is_jwt_format(token) and self.jwks_client:
validation_method = "jwt"
payload = await self._verify_jwt_signature(
token, skip_issuer_check=True
)
if payload:
record_oauth_token_validation("jwt", "valid")
else:
record_oauth_token_validation("jwt", "invalid")
return None
else:
# Fall back to introspection for opaque tokens
validation_method = "introspect"
payload = await self._introspect_token(token)
if payload:
record_oauth_token_validation("introspect", "valid")
else:
record_oauth_token_validation("introspect", "invalid")
return None
# Check payload is valid
if not payload:
return None
# Skip audience validation - any valid Nextcloud token is accepted
logger.debug(
f"Management API token validated (no audience check) for user: {payload.get('sub')}"
)
# Cache and return the token
return self._create_access_token_with_cache_key(token, payload, cache_key)
except Exception as e:
logger.error(f"Management API token verification failed: {e}")
record_oauth_token_validation(validation_method, "error")
return None
def _has_mcp_audience(self, payload: dict[str, Any]) -> bool: def _has_mcp_audience(self, payload: dict[str, Any]) -> bool:
""" """
Check if token has MCP audience. Check if token has MCP audience.
@@ -230,12 +340,15 @@ class UnifiedTokenVerifier(TokenVerifier):
""" """
return "." in token and token.count(".") == 2 return "." in token and token.count(".") == 2
async def _verify_jwt_signature(self, token: str) -> dict[str, Any] | None: async def _verify_jwt_signature(
self, token: str, skip_issuer_check: bool = False
) -> dict[str, Any] | None:
""" """
Verify JWT token with signature validation using JWKS. Verify JWT token with signature validation using JWKS.
Args: Args:
token: JWT token to verify token: JWT token to verify
skip_issuer_check: If True, skip issuer validation (for management API tokens)
Returns: Returns:
Decoded payload if valid, None if invalid Decoded payload if valid, None if invalid
@@ -248,25 +361,22 @@ class UnifiedTokenVerifier(TokenVerifier):
# Verify and decode JWT # Verify and decode JWT
# Note: We don't validate audience here - that's done separately based on mode # Note: We don't validate audience here - that's done separately based on mode
# Issuer validation can be skipped for management API tokens (from Astrolabe)
should_verify_issuer = (
not skip_issuer_check
and hasattr(self.settings, "oidc_issuer")
and self.settings.oidc_issuer
)
payload = jwt.decode( payload = jwt.decode(
token, token,
signing_key.key, signing_key.key,
algorithms=["RS256"], algorithms=["RS256"],
issuer=( issuer=(self.settings.oidc_issuer if should_verify_issuer else None),
self.settings.oidc_issuer
if hasattr(self.settings, "oidc_issuer")
else None
),
options={ options={
"verify_signature": True, "verify_signature": True,
"verify_exp": True, "verify_exp": True,
"verify_iat": True, "verify_iat": True,
"verify_iss": ( "verify_iss": should_verify_issuer,
True
if hasattr(self.settings, "oidc_issuer")
and self.settings.oidc_issuer
else False
),
"verify_aud": False, # We handle audience validation separately "verify_aud": False, # We handle audience validation separately
}, },
) )
@@ -358,6 +468,24 @@ class UnifiedTokenVerifier(TokenVerifier):
token: The bearer token token: The bearer token
payload: Validated token payload payload: Validated token payload
Returns:
AccessToken object or None if required fields missing
"""
# Use default cache key (hash of token)
cache_key = hashlib.sha256(token.encode()).hexdigest()
return self._create_access_token_with_cache_key(token, payload, cache_key)
def _create_access_token_with_cache_key(
self, token: str, payload: dict[str, Any], cache_key: str
) -> AccessToken | None:
"""
Create AccessToken object from validated token payload with custom cache key.
Args:
token: The bearer token
payload: Validated token payload
cache_key: Key to use for caching (allows separate caches for MCP vs management API)
Returns: Returns:
AccessToken object or None if required fields missing AccessToken object or None if required fields missing
""" """
@@ -382,14 +510,13 @@ class UnifiedTokenVerifier(TokenVerifier):
logger.warning("No 'exp' claim in token, using default TTL") logger.warning("No 'exp' claim in token, using default TTL")
exp = int(time.time() + self.cache_ttl) exp = int(time.time() + self.cache_ttl)
# Cache the result # Cache the result with the provided key
token_hash = hashlib.sha256(token.encode()).hexdigest()
userinfo = { userinfo = {
"sub": username, "sub": username,
"scope": scope_string, "scope": scope_string,
**{k: v for k, v in payload.items() if k not in ["sub", "scope"]}, **{k: v for k, v in payload.items() if k not in ["sub", "scope"]},
} }
self._token_cache[token_hash] = (userinfo, exp) self._token_cache[cache_key] = (userinfo, exp)
return AccessToken( return AccessToken(
token=token, token=token,
+16 -7
View File
@@ -37,7 +37,7 @@ async def test_capture_settings_page(browser, configure_astrolabe_for_mcp_server
# Navigate to settings # Navigate to settings
logger.info("Navigating to personal MCP settings...") logger.info("Navigating to personal MCP settings...")
await page.goto(f"{nextcloud_host}/settings/user/mcp") await page.goto(f"{nextcloud_host}/settings/user/astrolabe")
await page.wait_for_load_state("networkidle") await page.wait_for_load_state("networkidle")
# Capture page content # Capture page content
@@ -52,13 +52,13 @@ async def test_capture_settings_page(browser, configure_astrolabe_for_mcp_server
logger.info(f"Page URL: {page.url}") logger.info(f"Page URL: {page.url}")
logger.info(f"Page title: {await page.title()}") logger.info(f"Page title: {await page.title()}")
# Check for key strings # Check for key strings (Vue 3 UI)
checks = [ checks = [
"Authorize Access", "Enable Semantic Search", # oauth-required.php authorization button
"Authorization Required", "Service Status", # personal.php when authorized
"MCP Server", "Background Sync Access", # personal.php when authorized
"Sign In Again", "What happens next?", # oauth-required.php steps
"astrolabe", "Astrolabe", # Header
] ]
for check in checks: for check in checks:
@@ -75,6 +75,15 @@ async def test_capture_settings_page(browser, configure_astrolabe_for_mcp_server
for i, link_text in enumerate(links[:10]): for i, link_text in enumerate(links[:10]):
logger.info(f" Link {i}: {link_text}") logger.info(f" Link {i}: {link_text}")
# Check the Enable Semantic Search button href
try:
btn = page.locator('a:has-text("Enable Semantic Search")')
if await btn.count() > 0:
href = await btn.get_attribute("href")
logger.info(f"Enable Semantic Search button href: {href}")
except Exception as e:
logger.warning(f"Could not get button href: {e}")
# Check for error messages # Check for error messages
if "error" in page_content.lower(): if "error" in page_content.lower():
logger.warning("Page contains 'error' keyword") logger.warning("Page contains 'error' keyword")
+65 -47
View File
@@ -105,21 +105,26 @@ async def authorized_nc_session(
# Step 2: Navigate to personal MCP settings # Step 2: Navigate to personal MCP settings
logger.info("Navigating to personal MCP settings...") logger.info("Navigating to personal MCP settings...")
await page.goto(f"{host}/settings/user/mcp") await page.goto(f"{host}/settings/user/astrolabe")
await page.wait_for_load_state("networkidle") await page.wait_for_load_state("networkidle")
page_content = await page.content() page_content = await page.content()
# Step 3: Check if authorization is needed # Step 3: Check if authorization is needed
if "Authorize Access" in page_content or "authorize" in page_content.lower(): # Vue 3 UI shows "Enable Semantic Search" when not authorized
if (
"Enable Semantic Search" in page_content
or "What happens next?" in page_content
):
logger.info("User not authorized yet - initiating OAuth flow...") logger.info("User not authorized yet - initiating OAuth flow...")
# Click "Authorize Access" button # Click "Enable Semantic Search" button (Vue 3 template text)
authorize_selectors = [ authorize_selectors = [
'button:has-text("Authorize")', 'a:has-text("Enable Semantic Search")',
'a:has-text("Authorize")', 'button:has-text("Enable Semantic Search")',
'[href*="oauth/authorize"]', 'a:has-text("Sign In Again")',
'button:has-text("Connect")', "a.button.primary",
'[href*="oauth/login"]',
] ]
clicked = False clicked = False
@@ -142,9 +147,17 @@ async def authorized_nc_session(
# Wait for page to load after clicking # Wait for page to load after clicking
await page.wait_for_load_state("networkidle", timeout=10000) await page.wait_for_load_state("networkidle", timeout=10000)
current_url = page.url current_url = page.url
logger.info(f"After clicking authorize, current URL: {current_url}")
# Take screenshot for debugging
await page.screenshot(path="/tmp/nc-php-app-after-authorize-click.png")
logger.info("Screenshot saved to /tmp/nc-php-app-after-authorize-click.png")
# Handle OAuth consent if needed # Handle OAuth consent if needed
if "/apps/oidc/authorize" in current_url: if (
"/apps/oidc/authorize" in current_url
or "/apps/oidc/consent" in current_url
):
logger.info("On OIDC authorization page - granting consent...") logger.info("On OIDC authorization page - granting consent...")
consent_selectors = [ consent_selectors = [
@@ -163,7 +176,7 @@ async def authorized_nc_session(
continue continue
# Wait for redirect back to settings # Wait for redirect back to settings
await page.wait_for_url(f"{host}/settings/user/mcp", timeout=15000) await page.wait_for_url(f"{host}/settings/user/astrolabe", timeout=15000)
await page.wait_for_load_state("networkidle") await page.wait_for_load_state("networkidle")
logger.info("✓ OAuth authorization completed") logger.info("✓ OAuth authorization completed")
@@ -197,28 +210,32 @@ class TestNcPhpAppOAuth:
host = authorized_nc_session["host"] host = authorized_nc_session["host"]
# Navigate to settings (may already be there) # Navigate to settings (may already be there)
await page.goto(f"{host}/settings/user/mcp") await page.goto(f"{host}/settings/user/astrolabe")
await page.wait_for_load_state("networkidle") await page.wait_for_load_state("networkidle")
page_content = await page.content() page_content = await page.content()
# Look for indicators that authorization succeeded # Look for indicators that authorization succeeded (Vue 3 personal.php template)
# These must be unique to the authorized state (not found in oauth-required.php)
success_indicators = [ success_indicators = [
"Connected", "Service Status",
"Disconnect", "Background Sync Access",
"Server Connection", "Manage Connection",
"Session Information", "Revoke Access",
"MCP Server", "Service URL",
] ]
has_success_indicator = any( found_indicators = [ind for ind in success_indicators if ind in page_content]
indicator in page_content for indicator in success_indicators has_success_indicator = len(found_indicators) > 0
)
# Always take screenshot for debugging
screenshot_path = "/tmp/nc-php-app-auth-check.png"
await page.screenshot(path=screenshot_path)
logger.info(f"Authorization check screenshot: {screenshot_path}")
logger.info(f"Found success indicators: {found_indicators}")
if not has_success_indicator: if not has_success_indicator:
screenshot_path = "/tmp/nc-php-app-auth-check.png" logger.error("Authorization check failed.")
await page.screenshot(path=screenshot_path)
logger.error(f"Authorization check failed. Screenshot: {screenshot_path}")
assert has_success_indicator, "Settings page should show user is authorized" assert has_success_indicator, "Settings page should show user is authorized"
logger.info("✓ Authorization verification passed") logger.info("✓ Authorization verification passed")
@@ -232,7 +249,7 @@ class TestNcPhpAppOAuth:
page = authorized_nc_session["page"] page = authorized_nc_session["page"]
host = authorized_nc_session["host"] host = authorized_nc_session["host"]
await page.goto(f"{host}/settings/user/mcp") await page.goto(f"{host}/settings/user/astrolabe")
await page.wait_for_load_state("networkidle") await page.wait_for_load_state("networkidle")
page_content = await page.content() page_content = await page.content()
@@ -243,12 +260,12 @@ class TestNcPhpAppOAuth:
logger.info(f"Screenshot saved: {screenshot_path}") logger.info(f"Screenshot saved: {screenshot_path}")
logger.info(f"Page content excerpt: {page_content[:1000]}") logger.info(f"Page content excerpt: {page_content[:1000]}")
# Verify session information is visible - these are the actual labels from template # Verify session information is visible (Vue 3 personal.php template)
session_indicators = [ session_indicators = [
"Server Connection", "Service Status",
"Session Information", "Service URL",
"Connection Management", "Version",
"MCP Server", "Background Sync Access",
] ]
found_indicators = [ind for ind in session_indicators if ind in page_content] found_indicators = [ind for ind in session_indicators if ind in page_content]
@@ -270,17 +287,17 @@ class TestNcPhpAppOAuth:
host = authorized_nc_session["host"] host = authorized_nc_session["host"]
# Check personal settings page shows server status # Check personal settings page shows server status
await page.goto(f"{host}/settings/user/mcp") await page.goto(f"{host}/settings/user/astrolabe")
await page.wait_for_load_state("networkidle") await page.wait_for_load_state("networkidle")
page_content = await page.content() page_content = await page.content()
# Look for data that comes from management API or template structure # Look for data that comes from management API or template structure (Vue 3)
api_indicators = [ api_indicators = [
"Server Connection", # Section header "Service Status", # Section header
"Server URL", # Server info "Service URL", # Server info from API
"Connection Management", # Connection section "Version", # Server version from management API
"Vector Visualization", # Vector sync section "Semantic Search", # Vector sync status
] ]
found_api_data = [ind for ind in api_indicators if ind in page_content] found_api_data = [ind for ind in api_indicators if ind in page_content]
@@ -298,23 +315,24 @@ class TestNcPhpAppOAuth:
page = authorized_nc_session["page"] page = authorized_nc_session["page"]
host = authorized_nc_session["host"] host = authorized_nc_session["host"]
await page.goto(f"{host}/settings/admin/mcp") await page.goto(f"{host}/settings/admin/astrolabe")
await page.wait_for_load_state("networkidle") await page.wait_for_load_state("networkidle")
page_content = await page.content() page_content = await page.content()
# Admin page should show server status # Admin page should show server status (Vue 3 AdminSettings.vue)
admin_indicators = [ admin_indicators = [
"MCP Server", "Astrolabe",
"Server Status", "Service Status",
"Version", "Version",
"Semantic Search",
] ]
found_indicators = [ind for ind in admin_indicators if ind in page_content] found_indicators = [ind for ind in admin_indicators if ind in page_content]
# Admin page should at least show the MCP Server header # Admin page should at least show the Astrolabe header or Service Status
assert "MCP Server" in page_content or "mcp" in page_content.lower(), ( assert "Astrolabe" in page_content or "Service Status" in page_content, (
"Admin settings page should show MCP Server section" "Admin settings page should show Astrolabe section"
) )
logger.info(f"✓ Admin settings page verified - found: {found_indicators}") logger.info(f"✓ Admin settings page verified - found: {found_indicators}")
@@ -355,13 +373,13 @@ class TestNcPhpAppDisconnect:
await page.wait_for_url(f"{host}/apps/dashboard/", timeout=10000) await page.wait_for_url(f"{host}/apps/dashboard/", timeout=10000)
# Navigate to personal settings # Navigate to personal settings
await page.goto(f"{host}/settings/user/mcp") await page.goto(f"{host}/settings/user/astrolabe")
await page.wait_for_load_state("networkidle") await page.wait_for_load_state("networkidle")
page_content = await page.content() page_content = await page.content()
# Check if user is authorized # Check if user is authorized (Vue 3 personal.php shows Disconnect/Revoke when authorized)
if "Disconnect" not in page_content: if "Disconnect" not in page_content and "Revoke Access" not in page_content:
pytest.skip("User not authorized - cannot test disconnect") pytest.skip("User not authorized - cannot test disconnect")
# Click disconnect button # Click disconnect button
@@ -384,10 +402,10 @@ class TestNcPhpAppDisconnect:
# Wait for page reload # Wait for page reload
await page.wait_for_load_state("networkidle") await page.wait_for_load_state("networkidle")
# Verify we're back to "Authorize Access" state # Verify we're back to "Enable Semantic Search" state (Vue 3 oauth-required.php)
page_content = await page.content() page_content = await page.content()
assert "Authorize" in page_content, ( assert "Enable Semantic Search" in page_content, (
"Settings page should show 'Authorize Access' after disconnect" "Settings page should show 'Enable Semantic Search' after disconnect"
) )
logger.info("✓ Disconnect flow test passed") logger.info("✓ Disconnect flow test passed")
+13 -2
View File
@@ -335,9 +335,10 @@ class OAuthController extends Controller {
]); ]);
} else { } else {
// Fall back to Nextcloud's OIDC app // Fall back to Nextcloud's OIDC app
// Use internal localhost URL for HTTP request (always accessible from inside container) // Use internal localhost URL for HTTP request (accessible from inside container)
// The OIDC discovery response will contain proper external URLs based on overwrite.cli.url // We'll transform the returned URLs to external format after discovery
$discoveryUrl = 'http://localhost/.well-known/openid-configuration'; $discoveryUrl = 'http://localhost/.well-known/openid-configuration';
$internalBaseUrl = 'http://localhost';
$this->logger->info('Using Nextcloud OIDC app as IdP (internal request)', [ $this->logger->info('Using Nextcloud OIDC app as IdP (internal request)', [
'discovery_url' => $discoveryUrl, 'discovery_url' => $discoveryUrl,
@@ -368,6 +369,16 @@ class OAuthController extends Controller {
} }
$authEndpoint = $discovery['authorization_endpoint']; $authEndpoint = $discovery['authorization_endpoint'];
// Transform internal URL to external URL if using Nextcloud OIDC app
// The discovery was done via internal http://localhost but browsers need
// the external URL (e.g., http://localhost:8080)
if (isset($internalBaseUrl)) {
$externalBaseUrl = $this->urlGenerator->getAbsoluteURL('/');
$externalBaseUrl = rtrim($externalBaseUrl, '/');
$authEndpoint = str_replace($internalBaseUrl, $externalBaseUrl, $authEndpoint);
}
$this->logger->info('buildAuthorizationUrl: OIDC discovery succeeded', [ $this->logger->info('buildAuthorizationUrl: OIDC discovery succeeded', [
'auth_endpoint' => $authEndpoint, 'auth_endpoint' => $authEndpoint,
'token_endpoint' => $discovery['token_endpoint'] ?? 'not_set', 'token_endpoint' => $discovery['token_endpoint'] ?? 'not_set',