0 && Permissions::can_use_abilities() ) { self::$context = [ 'credential_id' => null, 'user_id' => $user_id, 'access_level' => ToolDescriptor::ACCESS_FULL, ]; return self::$context; } return null; } /** * Map a secret to a context, checking direct credentials then delegated ones. * * @param string $secret * @return array|null */ private static function resolve_secret( string $secret ): ?array { $record = Credentials::find_by_secret( $secret ); if ( null !== $record ) { if ( ! self::user_still_eligible( (int) $record['user_id'] ) ) { return null; } Credentials::touch( (string) $record['id'] ); return [ 'credential_id' => (string) $record['id'], 'user_id' => (int) $record['user_id'], 'access_level' => Credentials::normalize_level( (string) $record['access_level'] ), ]; } if ( ! class_exists( RecordStore::class ) ) { return null; } $issued = RecordStore::find_access_token( $secret ); if ( null === $issued || ! self::user_still_eligible( (int) $issued['user_id'] ) ) { return null; } return [ 'credential_id' => (string) ( $issued['client_id'] ?? 'oauth' ), 'user_id' => (int) $issued['user_id'], 'access_level' => Credentials::normalize_level( (string) $issued['access_level'] ), ]; } /** * A credential stops granting access the moment its bound account is deleted * or drops below the required capability (FR-018) — the credential itself * need not be revoked for access to end. * * @param int $user_id * @return bool */ private static function user_still_eligible( int $user_id ): bool { if ( $user_id <= 0 ) { return false; } $user = get_userdata( $user_id ); if ( ! $user ) { return false; } return user_can( $user, 'manage_options' ); } /** * Header only — never a path or query parameter (FR-022). A bearer secret in * a URL lands in access logs, proxy logs, browser history and `Referer`. * * @return string */ private static function bearer_token(): string { $header = ''; if ( ! empty( $_SERVER['HTTP_AUTHORIZATION'] ) ) { $header = sanitize_text_field( wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ) ); } elseif ( ! empty( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ) { // Apache strips Authorization unless explicitly passed through. $header = sanitize_text_field( wp_unslash( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ); } if ( '' === $header || 0 !== stripos( $header, 'bearer ' ) ) { return ''; } return trim( substr( $header, 7 ) ); } /** * The WWW-Authenticate challenge — how a URL-only client discovers where to * authenticate (FR-029). * * @return string */ public static function challenge_header(): string { // The RESOURCE-SPECIFIC metadata URL (RFC 9728 §3.1: the resource's path // is appended to the well-known prefix), not the bare one. // // This header is the authoritative pointer — it is how a client that // holds only a site address finds the auth flow, and it is followed in // preference to guessing. Aiming it at the bare path aimed clients at a // slot shared with every other OAuth-serving plugin on the site, which is // how ChatGPT ended up being handed a different plugin's authorization // endpoint for Templately's own resource. $path = (string) wp_parse_url( RecordStore::audience(), PHP_URL_PATH ); return sprintf( 'Bearer resource_metadata="%s"', esc_url_raw( home_url( '/.well-known/oauth-protected-resource' . $path ) ) ); } /** * Test seam. */ public static function reset(): void { self::$context = null; } }